Compare commits
50 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c8edbeb328 | |||
| 82b412f718 | |||
| 222b144eed | |||
| 7382e13371 | |||
| a5e797e5db | |||
| 0732a160b5 | |||
| b869de33a5 | |||
| b663f2a994 | |||
| c008f43a7e | |||
| 167755fdec | |||
| 050eaf6481 | |||
| 5f03d5aea6 | |||
| 00a5c3a075 | |||
| 33d6904e66 | |||
| b2ae7f1c50 | |||
| 1abd42e683 | |||
| 024ffed1ca | |||
| e9b84b7a1b | |||
| 1316ed05b3 | |||
| 34207456e6 | |||
| 6d906349bf | |||
| 143c4035ec | |||
| 99df3e204f | |||
| 95e3af0800 | |||
| 8421b7b668 | |||
| 0f426ef699 | |||
| be243aafe8 | |||
| 3f90221f40 | |||
| ed0a8e7656 | |||
| c89ce8e172 | |||
| d81cade7cc | |||
| 406c638451 | |||
| b1369068ae | |||
| a8d57f0d69 | |||
| 882101b586 | |||
| c6d401685f | |||
| 6481708def | |||
| 706d402c1e | |||
| ed08ff84ff | |||
| 7fe80220f1 | |||
| ccdcdd6116 | |||
| ccc725f36e | |||
| fed99ac03d | |||
| a01731d986 | |||
| 1d9f0b6c64 | |||
| 261e243070 | |||
| 5470fbe406 | |||
| 7102687ee8 | |||
| 5b69e3ec4c | |||
| eebca9900b |
+19
@@ -135,3 +135,22 @@ new-layouts/
|
||||
# writes event photos into backend/storage/, and the narrower
|
||||
# business-docs-only rule let `git add -A` sweep them into a commit.
|
||||
backend/storage/
|
||||
|
||||
# Python bytecode. The ML sidecar lives on main only, so this branch never
|
||||
# needed the rule — which is how a `git add -A` from a shared working tree
|
||||
# committed 16 .pyc files here in #1247.
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# Issue / PR screenshots belong on a `screenshots/*` branch, never on main or
|
||||
# stable — that is what those branches exist for. Two landed at the repo root
|
||||
# on main in #1241 and shipped as part of the source tree; nothing stopped it.
|
||||
#
|
||||
# Anchored with a leading slash so docs/ keeps its own images.
|
||||
/issue-*.png
|
||||
/issue-*.jpg
|
||||
/screenshot-*.png
|
||||
/screenshot-*.jpg
|
||||
/*-screenshot.png
|
||||
/*-screenshot.jpg
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
{".":"3.46.7"}
|
||||
{".":"3.46.13"}
|
||||
|
||||
@@ -5,6 +5,87 @@ 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.46.13](https://github.com/PicPeak/picpeak/compare/v3.46.12...v3.46.13) (2026-09-11)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **admin:** keep header-style tiles from overflowing their cards (stable) ([#1423](https://github.com/PicPeak/picpeak/issues/1423)) ([82b412f](https://github.com/PicPeak/picpeak/commit/82b412f71855a8f60fb47be6bb37bdaf16bf4da1))
|
||||
* **backend:** bump sharp, nodemailer, multer, js-yaml, joi for security fixes (stable) ([#1375](https://github.com/PicPeak/picpeak/issues/1375)) ([024ffed](https://github.com/PicPeak/picpeak/commit/024ffed1ca5e87a5c1b7ef2bc3b94756945ac771))
|
||||
* **backend:** check the revocation store on gallery access (stable) ([#1388](https://github.com/PicPeak/picpeak/issues/1388)) ([b869de3](https://github.com/PicPeak/picpeak/commit/b869de33a5b5cf927d18776dfaece432480a5483))
|
||||
* **backend:** contain and sanitize the SQLite restore source path ([#1393](https://github.com/PicPeak/picpeak/issues/1393)) ([b663f2a](https://github.com/PicPeak/picpeak/commit/b663f2a99403718328ddb299110ab46830ba27b7))
|
||||
* **backend:** enforce event ownership on short URL deletion ([#1394](https://github.com/PicPeak/picpeak/issues/1394)) ([1abd42e](https://github.com/PicPeak/picpeak/commit/1abd42e683bf99edd56eceecc7b8181042610462))
|
||||
* **backend:** reject a replayed TOTP code within its validity window (stable) ([#1398](https://github.com/PicPeak/picpeak/issues/1398)) ([33d6904](https://github.com/PicPeak/picpeak/commit/33d6904e66d23c0b500d3393354c307f618757e9))
|
||||
* **backend:** require actor to hold every permission of a role they grant (stable) ([#1380](https://github.com/PicPeak/picpeak/issues/1380)) ([050eaf6](https://github.com/PicPeak/picpeak/commit/050eaf6481b1d70db120da1d99321548756cd030))
|
||||
* **backend:** shorten payment-check token TTL and notify admin on use ([#1392](https://github.com/PicPeak/picpeak/issues/1392)) ([b2ae7f1](https://github.com/PicPeak/picpeak/commit/b2ae7f1c5066434838d295f834aa1286c6751132))
|
||||
* **backend:** use the strong password generator for resets and enforce must_change_password ([#1396](https://github.com/PicPeak/picpeak/issues/1396)) ([167755f](https://github.com/PicPeak/picpeak/commit/167755fdecbd0537ea63d006f648890bad83dd5b))
|
||||
* **backend:** validate business-profile logo uploads by content, not filename (stable) ([#1395](https://github.com/PicPeak/picpeak/issues/1395)) ([00a5c3a](https://github.com/PicPeak/picpeak/commit/00a5c3a0752bb30446f4cb1ed24dd95191a284c5))
|
||||
* **backend:** validate event id before using it in the logo storage filename (stable) ([#1397](https://github.com/PicPeak/picpeak/issues/1397)) ([5f03d5a](https://github.com/PicPeak/picpeak/commit/5f03d5aea658851c53e6f9a780346c25b9ca56c2))
|
||||
* **backend:** validate the S3 endpoint host before the restore download (stable) ([#1391](https://github.com/PicPeak/picpeak/issues/1391)) ([c008f43](https://github.com/PicPeak/picpeak/commit/c008f43a7e961e20cb50c389dc61a17e3c4d16db))
|
||||
* **gallery:** bound and reclaim storage reads in the guest download routes (stable) ([#1416](https://github.com/PicPeak/picpeak/issues/1416)) ([a5e797e](https://github.com/PicPeak/picpeak/commit/a5e797e5db6ef8899cf0ac92065fc08fbc8ed43d))
|
||||
* **gallery:** bound the cached-zip builder's reads and cap rebuild concurrency (stable) ([#1421](https://github.com/PicPeak/picpeak/issues/1421)) ([222b144](https://github.com/PicPeak/picpeak/commit/222b144eed503e337ef6441c93e44a82a39e8d7d))
|
||||
* **gallery:** keep videos playable under enhanced and maximum protection (stable) ([#1408](https://github.com/PicPeak/picpeak/issues/1408)) ([0732a16](https://github.com/PicPeak/picpeak/commit/0732a160b562647066bb443cc24f78add7fa51bc))
|
||||
* **gallery:** let an admin preview a draft through its short share URL (stable) ([#1407](https://github.com/PicPeak/picpeak/issues/1407)) ([7382e13](https://github.com/PicPeak/picpeak/commit/7382e133714b487333aacb37f7f167f42c66de5c))
|
||||
|
||||
## [3.46.12](https://github.com/PicPeak/picpeak/compare/v3.46.11...v3.46.12) (2026-09-10)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **backup:** honor the configured database-backup destination path ([#1367](https://github.com/PicPeak/picpeak/issues/1367)) ([3420745](https://github.com/PicPeak/picpeak/commit/34207456e6c1ed2908ab34335ab0fd4d99503e90))
|
||||
* **video:** try metadata extraction and thumbnail generation independently ([#1372](https://github.com/PicPeak/picpeak/issues/1372)) ([1316ed0](https://github.com/PicPeak/picpeak/commit/1316ed05b3938da59eb8cb263a5334909e351bfe))
|
||||
|
||||
## [3.46.11](https://github.com/PicPeak/picpeak/compare/v3.46.10...v3.46.11) (2026-09-08)
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* align stable security and backport policy ([#1352](https://github.com/PicPeak/picpeak/issues/1352)) ([143c403](https://github.com/PicPeak/picpeak/commit/143c4035ec38683634d0e3d493032e2965f4a46f))
|
||||
|
||||
## [3.46.10](https://github.com/PicPeak/picpeak/compare/v3.46.9...v3.46.10) (2026-09-07)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **security:** bump sanitize-html to 2.17.7 ([0f426ef](https://github.com/PicPeak/picpeak/commit/0f426ef69968b395c6e3fbd0301fe3a7759a5f44))
|
||||
* **security:** bump sanitize-html to 2.17.7 (stable) ([95e3af0](https://github.com/PicPeak/picpeak/commit/95e3af080039f2d31e1cb9c85a3d93b22c80ba7c))
|
||||
* **setup:** require Node 22.12 for sanitize-html ([8421b7b](https://github.com/PicPeak/picpeak/commit/8421b7b668484f87cd2bacda8fb4d95a3bc07ab5))
|
||||
|
||||
## [3.46.9](https://github.com/PicPeak/picpeak/compare/v3.46.8...v3.46.9) (2026-09-03)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **security:** batch 1 (stable) — zxcvbn DoS, revocation forgery, unlink traversals, stored Content-Type, edge middleware ([3f90221](https://github.com/PicPeak/picpeak/commit/3f90221f40b604dd5cebc016aad9478e84035b97))
|
||||
* **security:** bound password input before zxcvbn, and drop the legacy media mounts ([ed08ff8](https://github.com/PicPeak/picpeak/commit/ed08ff84ff014226f1e17cc17167f80afa366f7c))
|
||||
* **security:** close three middleware gaps around the API edge ([b136906](https://github.com/PicPeak/picpeak/commit/b1369068ae1327fc29e8aa671029548e2e93d827))
|
||||
* **security:** contain logo, favicon and PDF-logo unlinks to their upload directories ([882101b](https://github.com/PicPeak/picpeak/commit/882101b58670e99ac3aea560b83fc4123fe4b359))
|
||||
* **security:** enforce the strength-endpoint validators, and stop the generator spinning ([706d402](https://github.com/PicPeak/picpeak/commit/706d402c1e979d8419396c451487fb9be756a449))
|
||||
* **security:** harden four smaller gallery and contract paths, drop the unmounted photo auth middleware ([d81cade](https://github.com/PicPeak/picpeak/commit/d81cade7cc9a39179b05ace5ae47b13bbe1d8196))
|
||||
* **security:** never serve a photo under its stored MIME, and stop trusting the chunked-upload type ([a8d57f0](https://github.com/PicPeak/picpeak/commit/a8d57f0d696b9e0e92d6ae91beff9f3ad0fa1695))
|
||||
* **security:** stop reflecting submitted passwords in validation errors ([6481708](https://github.com/PicPeak/picpeak/commit/6481708def49bc9cdf424752a633e320813cf280))
|
||||
* **security:** stop reflecting submitted values in validation errors everywhere, cap credential lengths, close the login timing oracle ([406c638](https://github.com/PicPeak/picpeak/commit/406c6384513da2d7582dc223d799fba7cad56745))
|
||||
* **security:** verify the signature before writing a token to the revocation list ([c6d4016](https://github.com/PicPeak/picpeak/commit/c6d401685f4eb4d9fd5fb70636962d73fc631cde))
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* say the upload allow-list covers every path, video extensions must be added ([c89ce8e](https://github.com/PicPeak/picpeak/commit/c89ce8e1721adfd67598cf35ae307e97ed185827))
|
||||
|
||||
## [3.46.8](https://github.com/PicPeak/picpeak/compare/v3.46.7...v3.46.8) (2026-09-01)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **archives:** take the restored category from the manifest ([#1240](https://github.com/PicPeak/picpeak/issues/1240)) (stable) ([#1243](https://github.com/PicPeak/picpeak/issues/1243)) ([261e243](https://github.com/PicPeak/picpeak/commit/261e243070b6082ccd8b972de68d2fee15329235))
|
||||
* **archives:** write a real timestamp on restored photos ([#1257](https://github.com/PicPeak/picpeak/issues/1257)) ([fed99ac](https://github.com/PicPeak/picpeak/commit/fed99ac03dfde03cf4c55fcb4b1419fea7564156))
|
||||
* **auth:** treat zxcvbn suggestions as advice, not blocking errors ([#1247](https://github.com/PicPeak/picpeak/issues/1247)) ([5b69e3e](https://github.com/PicPeak/picpeak/commit/5b69e3ec4c898204c9fcde0f1d49f24edc688891))
|
||||
* **auth:** treat zxcvbn suggestions as advice, not blocking errors ([#1247](https://github.com/PicPeak/picpeak/issues/1247)) ([eebca99](https://github.com/PicPeak/picpeak/commit/eebca9900b6f00b222eec16e27fa6be4fe2ce9fa))
|
||||
* **events:** apply the gallery password policy to publish ([#1255](https://github.com/PicPeak/picpeak/issues/1255)) ([1d9f0b6](https://github.com/PicPeak/picpeak/commit/1d9f0b6c6491cface22f12651131fd5dfba330f0))
|
||||
* **events:** delete stored objects when cascading an event delete ([#1245](https://github.com/PicPeak/picpeak/issues/1245)) ([7102687](https://github.com/PicPeak/picpeak/commit/7102687ee804140bfaca420d2eb7ec0078e50f25))
|
||||
* **gallery:** route single-photo downloads through the storage backend ([#1246](https://github.com/PicPeak/picpeak/issues/1246)) ([5470fbe](https://github.com/PicPeak/picpeak/commit/5470fbe4063c3d6c0aeeb50fdb1ce6af74df1b53))
|
||||
* **upload:** let Android guests reach the camera without breaking video ([#1248](https://github.com/PicPeak/picpeak/issues/1248)) ([ccc725f](https://github.com/PicPeak/picpeak/commit/ccc725f36edcf20643ab9c3a7aff16b5b93674c1))
|
||||
|
||||
## [3.46.7](https://github.com/PicPeak/picpeak/compare/v3.46.6...v3.46.7) (2026-08-28)
|
||||
|
||||
|
||||
|
||||
+5
-4
@@ -163,13 +163,14 @@ PicPeak runs on two long-lived branches:
|
||||
| Branch | Role | What targets it |
|
||||
|---|---|---|
|
||||
| **`main`** | Active development. The next release is being assembled here. | Feature PRs. Most bugfix PRs. |
|
||||
| **`stable`** | Curated release channel. Production-recommended. | Urgent bugfix backports only — small, surgical PRs that land cleanly without dragging in unrelated changes. |
|
||||
| **`stable`** | Curated release channel. Production-recommended. | Security fixes and regular bugfix backports, kept small and free of unrelated features. |
|
||||
|
||||
### Which branch should my PR target?
|
||||
|
||||
- **New feature** → target `main`.
|
||||
- **Bugfix that ONLY affects active dev** → target `main`.
|
||||
- **Bugfix that current stable users need** → open a small PR against `main`, AND a separate small PR against `stable` with the same change. Keep both surgical so each lands cleanly.
|
||||
- **Bugfix that current stable users need** → target `main`; regular bug fixes are generally backported automatically to `stable`. Maintainers handle conflicts or create a separate focused backport PR when needed.
|
||||
- **Security vulnerability** → report privately using [SECURITY.md](SECURITY.md). Security fixes are always released on both `stable` and `main`; coordinate any fix with the maintainers before opening a public PR.
|
||||
|
||||
**Hard rule on PR scope**: bugfix PRs against `stable` must be small enough to backport without conflict. Omnibus PRs (e.g. five unrelated sub-features) are fine for `main`, but never for `stable` — they make the next `main → stable` merge painful and break the "stable is always shippable" invariant.
|
||||
|
||||
@@ -187,6 +188,6 @@ See [RELEASING.md](RELEASING.md) for the full operational doc (promotion criteri
|
||||
|
||||
- Create an [issue](https://github.com/PicPeak/picpeak/issues) for bugs or features
|
||||
- Join [discussions](https://github.com/PicPeak/picpeak/discussions) for questions
|
||||
- Security issues: Open a [security issue](https://github.com/PicPeak/picpeak/issues/new?labels=security) on GitHub
|
||||
- Security vulnerabilities: Follow the [security policy](SECURITY.md) and use [private vulnerability reporting](https://github.com/PicPeak/picpeak/security/advisories/new)
|
||||
|
||||
Thank you for contributing! 🎉
|
||||
Thank you for contributing! 🎉
|
||||
|
||||
+6
-1
@@ -62,13 +62,18 @@ The actual mechanics, in order:
|
||||
|
||||
## Hotfix path (backport to current stable)
|
||||
|
||||
If a critical bug or security issue affects the current stable and `main` has moved too far for a full promotion to be appropriate, backport just the fix:
|
||||
Regular bug fixes are generally backported automatically from `main` to `stable`. Keep backports focused on the fix, without unrelated features, and resolve conflicts manually when needed.
|
||||
|
||||
**Security fixes are always released on both `stable` and `main`.** Do not wait for a full promotion to deliver a security update. A fix first applied to `stable` must also be forward-ported to `main`; a fix first applied to `main` must also reach `stable`. See [SECURITY.md](SECURITY.md) for the support policy.
|
||||
|
||||
When a backport needs manual handling:
|
||||
|
||||
1. Create a `security/cve-backport-X.Y.Z` or `fix/critical-X.Y.Z` branch off `stable`.
|
||||
2. Cherry-pick or hand-write the minimal fix.
|
||||
3. Open a PR to `stable` with the smallest possible diff.
|
||||
4. After merge, release-please will propose a patch-level stable release (e.g. `v3.55.1`).
|
||||
5. **Forward-port the fix to `main`** if it isn't already there. Otherwise the next full promotion will reintroduce the bug.
|
||||
6. For security fixes, verify that the fix has been published through **both** release channels; merging the code is only part of delivery.
|
||||
|
||||
PR #412 ("backport 18 dependency CVE patches from beta") is a worked example of this path (predates the rename; the mechanics are unchanged).
|
||||
|
||||
|
||||
+61
-68
@@ -1,88 +1,81 @@
|
||||
# Security Policy
|
||||
|
||||
## Scope
|
||||
|
||||
This policy covers the PicPeak backend, frontend, all-in-one (AIO) image, optional
|
||||
ML component, and the Docker images published by the PicPeak project. Other
|
||||
PicPeak repositories define their own supported versions and release channels.
|
||||
|
||||
## Supported Versions
|
||||
|
||||
We release patches for security vulnerabilities. Currently supported versions:
|
||||
Security support follows the current release channels:
|
||||
|
||||
| Version | Supported |
|
||||
| ------- | ------------------ |
|
||||
| 2.x.x | :white_check_mark: |
|
||||
| < 2.0 | :x: |
|
||||
| Version or channel | Security support |
|
||||
| --- | --- |
|
||||
| Latest stable release from `stable` | Supported; security fixes are published through this channel |
|
||||
| Latest beta release from `main` | Supported; security fixes are published through this channel |
|
||||
| Superseded stable or beta releases | Upgrade to the latest release in the same channel; older releases are not maintained separately |
|
||||
| 2.x and earlier | No longer supported |
|
||||
|
||||
See the [latest stable release](https://github.com/PicPeak/picpeak/releases/latest)
|
||||
and [all releases, including betas](https://github.com/PicPeak/picpeak/releases).
|
||||
Version numbers differ between channels; each channel receives its own updates.
|
||||
|
||||
### Security fixes and bug backports
|
||||
|
||||
**Security fixes are always released on both `stable` and `main`.** A fix that
|
||||
lands on one branch must also reach the other branch and be published through
|
||||
both release channels. Security updates do not wait for the next full
|
||||
`main`-to-`stable` promotion.
|
||||
|
||||
Regular bug fixes are also generally backported automatically to `stable`.
|
||||
Backports remain focused on the fix, without pulling in unrelated features.
|
||||
Maintainers resolve conflicts or handle a backport manually when necessary.
|
||||
|
||||
The [release process](RELEASING.md) describes backports, forward-ports and
|
||||
publication. Operators must apply the published updates to their installations.
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
We take the security of PicPeak seriously. If you have discovered a security vulnerability, please follow these steps:
|
||||
**Do not report vulnerabilities in public issues, discussions or pull requests.**
|
||||
|
||||
### 1. **Do NOT create a public GitHub issue**
|
||||
Report privately through:
|
||||
|
||||
### 2. Report the vulnerability privately by:
|
||||
- **Preferred:** Use [GitHub Private Vulnerability Reporting](https://github.com/PicPeak/picpeak/security/advisories/new)
|
||||
- **Alternative:** Email us at **info@picpeak.app** with the details
|
||||
- Include:
|
||||
- Description of the vulnerability
|
||||
- Steps to reproduce
|
||||
- Potential impact
|
||||
- Suggested fix (if any)
|
||||
- [GitHub Private Vulnerability Reporting](https://github.com/PicPeak/picpeak/security/advisories/new) (preferred).
|
||||
- Email **info@picpeak.app** if you cannot use GitHub's private reporting form.
|
||||
|
||||
### 3. You can expect:
|
||||
- Acknowledgment within 48 hours
|
||||
- Regular updates on our progress
|
||||
- Credit in the fix announcement (unless you prefer to remain anonymous)
|
||||
Include the affected component, version or image tag, deployment method,
|
||||
reproduction steps, expected impact and any suggested fix. Share only the
|
||||
information needed to reproduce the problem; remove credentials and personal
|
||||
data from logs or examples.
|
||||
|
||||
## Security Measures
|
||||
We aim to acknowledge reports within 48 hours. This is a response target, not a
|
||||
guaranteed service level or a promised resolution time. We will provide progress
|
||||
updates and coordinate disclosure with the reporter. Reporter credit is optional;
|
||||
tell us if you prefer to remain anonymous.
|
||||
|
||||
PicPeak implements several security measures:
|
||||
## Deployment Security
|
||||
|
||||
### Authentication & Authorization
|
||||
- JWT-based authentication with secure token storage
|
||||
- bcrypt password hashing with configurable rounds
|
||||
- Role-based access control for admin functions
|
||||
- Session timeout management
|
||||
Security depends on both the software and its configuration. Operators should:
|
||||
|
||||
### Input Validation
|
||||
- All user inputs are validated and sanitized
|
||||
- SQL injection prevention through parameterized queries
|
||||
- XSS protection via Content Security Policy
|
||||
- File upload restrictions and validation
|
||||
- Use HTTPS and configure the reverse proxy and trusted proxy settings correctly.
|
||||
- Use strong credentials and keep deployment secrets private.
|
||||
- Apply updates for the chosen release channel and restrict unnecessary network access.
|
||||
- Keep backups and verify that they can be restored.
|
||||
|
||||
### Rate Limiting
|
||||
- API rate limiting to prevent abuse
|
||||
- Brute force protection on authentication endpoints
|
||||
- Configurable limits per endpoint
|
||||
|
||||
### Data Protection
|
||||
- HTTPS enforcement in production
|
||||
- Secure cookie settings
|
||||
- CORS configuration
|
||||
- Sensitive data encryption
|
||||
|
||||
### Infrastructure
|
||||
- Regular dependency updates
|
||||
- Security headers (HSTS, X-Frame-Options, etc.)
|
||||
- Activity logging for audit trails
|
||||
- Automated backups
|
||||
|
||||
## Best Practices for Deployment
|
||||
|
||||
1. **Always use HTTPS** in production
|
||||
2. **Change default passwords** immediately
|
||||
3. **Keep dependencies updated** regularly
|
||||
4. **Configure firewall rules** appropriately
|
||||
5. **Monitor logs** for suspicious activity
|
||||
6. **Backup regularly** and test restoration
|
||||
See the deployment guides for [HTTPS](https://docs.picpeak.app/deployment/ssl-certificates),
|
||||
[reverse proxies](https://docs.picpeak.app/deployment/reverse-proxy),
|
||||
[security settings](https://docs.picpeak.app/guides/admin-settings/security)
|
||||
and [backup and restore](https://docs.picpeak.app/guides/backup-restore).
|
||||
|
||||
## Vulnerability Disclosure
|
||||
|
||||
We believe in responsible disclosure. Once a vulnerability is fixed:
|
||||
We coordinate disclosure with the reporter while preparing fixes. Security fixes
|
||||
are published through both supported channels. Advisories and release notes
|
||||
identify affected versions, the fixed version in each channel, the impact and
|
||||
any required mitigation or upgrade steps. Reporter credit is included with
|
||||
permission.
|
||||
|
||||
1. We'll publish a security advisory
|
||||
2. Credit researchers (with permission)
|
||||
3. Detail the impact and mitigation steps
|
||||
4. Release patches for all supported versions
|
||||
|
||||
## Contact
|
||||
|
||||
- Security issues: Email **info@picpeak.app** or use [GitHub Private Vulnerability Reporting](https://github.com/PicPeak/picpeak/security/advisories/new)
|
||||
- General support: [GitHub Issues](https://github.com/PicPeak/picpeak/issues)
|
||||
|
||||
Thank you for helping keep PicPeak and its users safe!
|
||||
For ordinary bugs and support requests, use
|
||||
[GitHub Issues](https://github.com/PicPeak/picpeak/issues) or
|
||||
[GitHub Discussions](https://github.com/PicPeak/picpeak/discussions).
|
||||
|
||||
@@ -0,0 +1,498 @@
|
||||
/**
|
||||
* Restoring an archive must put the photos back into their categories.
|
||||
*
|
||||
* The archive writer already persists `category_name` per photo in
|
||||
* `photos_manifest.json` — that is why the manifest exists, and the comment
|
||||
* above it says so: "(and category linkage) can't be derived from the
|
||||
* extracted files alone". The restore route then read only
|
||||
* `original_filename` from it and kept deriving the category from the ZIP's
|
||||
* first path segment.
|
||||
*
|
||||
* Archives store photos exactly as they sit on disk, so an event whose photos
|
||||
* live in the gallery root produces a FLAT zip. `path.dirname()` is '.' for
|
||||
* every entry, no category is resolved, and every restored photo lands with
|
||||
* `category_id = null` — silently, with a 200 response.
|
||||
*
|
||||
* These pin the manifest as the source of truth, with the directory as the
|
||||
* fallback that keeps foldered and legacy archives working.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
|
||||
describe('archive restore restores categories (flat archives included)', () => {
|
||||
let tmpDir; let db; let cleanup; let app; let storagePath;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-restore-cat-'));
|
||||
storagePath = path.join(tmpDir, 'storage');
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'data', 'test.db');
|
||||
process.env.STORAGE_PATH = storagePath;
|
||||
await fs.promises.mkdir(path.dirname(process.env.TEST_DATABASE_PATH), { recursive: true });
|
||||
await fs.promises.mkdir(path.join(storagePath, 'archives'), { recursive: true });
|
||||
|
||||
jest.resetModules();
|
||||
jest.doMock('../../src/middleware/auth', () => ({
|
||||
adminAuth: (req, _res, next) => { req.admin = { id: 1, username: 'tester' }; next(); },
|
||||
}));
|
||||
jest.doMock('../../src/middleware/permissions', () => ({
|
||||
requirePermission: () => (_req, _res, next) => next(),
|
||||
}));
|
||||
jest.doMock('../../src/middleware/ownership', () => ({
|
||||
requireEventOwnership: (_req, _res, next) => next(),
|
||||
}));
|
||||
|
||||
({ db, cleanup } = await require('./helpers/crmDb').bootCrmDb());
|
||||
// bootCrmDb points STORAGE_PATH at its own tmp dir; follow it rather than
|
||||
// fighting it, so the archives the tests write are where the route looks.
|
||||
storagePath = process.env.STORAGE_PATH;
|
||||
await fs.promises.mkdir(path.join(storagePath, 'archives'), { recursive: true });
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use('/admin/archives', require('../../src/routes/adminArchives'));
|
||||
}, 180000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
await fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await db('photos').del();
|
||||
await db('photo_categories').del();
|
||||
await db('events').del();
|
||||
});
|
||||
|
||||
/** A one-pixel JPEG is enough; the route only stats the extracted file. */
|
||||
const PIXEL = Buffer.from(
|
||||
'/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0a'
|
||||
+ 'HBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/wAALCAABAAEBAREA/8QAFAABAAAAAAAA'
|
||||
+ 'AAAAAAAAAAAACf/EABQQAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQEAAD8AKp//2Q==',
|
||||
'base64',
|
||||
);
|
||||
|
||||
async function writeArchive(name, entries) {
|
||||
// Required lazily: the suite calls jest.resetModules() in beforeAll, and
|
||||
// archiver's readable-stream copy does not survive being split across the
|
||||
// two module registries.
|
||||
const archiver = require('archiver');
|
||||
const archivePath = path.join(storagePath, 'archives', name);
|
||||
await new Promise((resolve, reject) => {
|
||||
const output = fs.createWriteStream(archivePath);
|
||||
const zip = archiver('zip', { zlib: { level: 0 } });
|
||||
output.on('close', resolve);
|
||||
zip.on('error', reject);
|
||||
zip.pipe(output);
|
||||
for (const [entryName, buffer] of Object.entries(entries)) {
|
||||
zip.append(buffer, { name: entryName });
|
||||
}
|
||||
zip.finalize();
|
||||
});
|
||||
return path.join('archives', name);
|
||||
}
|
||||
|
||||
async function seedArchivedEvent(archiveRelPath, slug) {
|
||||
const [row] = await db('events').insert({
|
||||
slug,
|
||||
event_type: 'wedding',
|
||||
event_name: slug,
|
||||
event_date: '2026-06-27',
|
||||
host_email: 'h@example.com',
|
||||
admin_email: 'a@example.com',
|
||||
password_hash: 'x',
|
||||
share_link: `${slug}-share`,
|
||||
expires_at: new Date().toISOString(),
|
||||
is_archived: 1, // sqlite stores booleans as 0/1, see utils/dbCompat
|
||||
archive_path: archiveRelPath,
|
||||
}).returning('id');
|
||||
return typeof row === 'object' ? row.id : row;
|
||||
}
|
||||
|
||||
const categoryOf = async (filename) => {
|
||||
const photo = await db('photos').where('filename', filename).first();
|
||||
if (!photo || !photo.category_id) return null;
|
||||
const category = await db('photo_categories').where('id', photo.category_id).first();
|
||||
return category ? category.name : null;
|
||||
};
|
||||
|
||||
it('takes the category from the manifest when the archive is flat', async () => {
|
||||
// Exactly the shape a gallery-root event archives to: no directories.
|
||||
const manifest = JSON.stringify([
|
||||
{ filename: 'a.jpg', original_filename: 'DSC_0001.jpg', category_name: 'Polterabend' },
|
||||
{ filename: 'b.jpg', original_filename: 'DSC_0002.jpg', category_name: 'Ceremony' },
|
||||
]);
|
||||
const archiveRelPath = await writeArchive('flat.zip', {
|
||||
'a.jpg': PIXEL,
|
||||
'b.jpg': PIXEL,
|
||||
'photos_manifest.json': Buffer.from(manifest, 'utf8'),
|
||||
});
|
||||
const eventId = await seedArchivedEvent(archiveRelPath, 'flat-event');
|
||||
|
||||
const res = await request(app).post(`/admin/archives/${eventId}/restore`).send({});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
// The whole bug: both of these used to be null.
|
||||
expect(await categoryOf('a.jpg')).toBe('Polterabend');
|
||||
expect(await categoryOf('b.jpg')).toBe('Ceremony');
|
||||
});
|
||||
|
||||
it('stores a real timestamp on restored photos, not "[object Object]"', async () => {
|
||||
// The jest+sqlite landmine: a Date handed to knex inside jest stores as
|
||||
// the literal string "[object Object]". Production writes ms-numbers and
|
||||
// is unaffected, so this only ever corrupts what tests read back — which
|
||||
// is how it survives unnoticed.
|
||||
const archiveRelPath = await writeArchive('timestamp.zip', {
|
||||
'individual/STAMPED.jpg': PIXEL,
|
||||
'photos_manifest.json': Buffer.from(JSON.stringify([
|
||||
{ filename: 'STAMPED.jpg', original_filename: 'STAMPED.jpg', category_name: 'Ceremony' },
|
||||
]), 'utf8'),
|
||||
});
|
||||
const eventId = await seedArchivedEvent(archiveRelPath, 'timestamp-event');
|
||||
|
||||
const res = await request(app).post(`/admin/archives/${eventId}/restore`).send({});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const photo = await db('photos').where({ event_id: eventId, filename: 'STAMPED.jpg' }).first();
|
||||
expect(String(photo.uploaded_at)).not.toBe('[object Object]');
|
||||
expect(Number.isNaN(new Date(photo.uploaded_at).getTime())).toBe(false);
|
||||
});
|
||||
|
||||
it('reuses an existing category row instead of creating a duplicate', async () => {
|
||||
const archiveRelPath = await writeArchive('reuse.zip', {
|
||||
'c.jpg': PIXEL,
|
||||
'photos_manifest.json': Buffer.from(JSON.stringify([
|
||||
{ filename: 'c.jpg', original_filename: 'DSC_0003.jpg', category_name: 'Party' },
|
||||
]), 'utf8'),
|
||||
});
|
||||
const eventId = await seedArchivedEvent(archiveRelPath, 'reuse-event');
|
||||
await db('photo_categories').insert({
|
||||
event_id: eventId, name: 'Party', slug: 'party', created_at: new Date(),
|
||||
});
|
||||
|
||||
const res = await request(app).post(`/admin/archives/${eventId}/restore`).send({});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
expect(await categoryOf('c.jpg')).toBe('Party');
|
||||
const rows = await db('photo_categories').where({ event_id: eventId, name: 'Party' });
|
||||
expect(rows).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('still falls back to the directory for legacy archives with no manifest', async () => {
|
||||
// No manifest at all — the shape every archive had before the manifest
|
||||
// landed. The directory is the only signal left, and it must keep working.
|
||||
//
|
||||
// `individual/` is what a REAL archive contains: entry names are the
|
||||
// storage key minus `events/active/{slug}`, and that layout is
|
||||
// `individual/` / `collages/`. Categories have never been directories, so
|
||||
// the fallback invents a category with that name — not useful, but better
|
||||
// than losing every category, and this pins what actually happens rather
|
||||
// than a category-shaped folder no archive produces.
|
||||
const archiveRelPath = await writeArchive('foldered.zip', {
|
||||
'individual/d.jpg': PIXEL,
|
||||
});
|
||||
const eventId = await seedArchivedEvent(archiveRelPath, 'foldered-event');
|
||||
|
||||
const res = await request(app).post(`/admin/archives/${eventId}/restore`).send({});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
expect(await categoryOf('d.jpg')).toBe('individual');
|
||||
});
|
||||
|
||||
it('reuses a GLOBAL category instead of cloning it into the event', async () => {
|
||||
// Seeded categories (Ceremony, Reception, ...) have event_id NULL. An
|
||||
// event-only lookup misses them, so the restore used to create a second
|
||||
// "Ceremony" — and because is_global defaults to TRUE, that duplicate then
|
||||
// appeared in every other event's category list.
|
||||
const [g] = await db('photo_categories').insert({
|
||||
event_id: null, name: 'Ceremony', slug: 'ceremony', is_global: true, created_at: new Date(),
|
||||
}).returning('id');
|
||||
const globalId = typeof g === 'object' ? g.id : g;
|
||||
|
||||
const archiveRelPath = await writeArchive('global.zip', {
|
||||
'individual/gl.jpg': PIXEL,
|
||||
'photos_manifest.json': Buffer.from(JSON.stringify([
|
||||
{ filename: 'gl.jpg', original_filename: 'DSC_1.jpg', category_name: 'Ceremony' },
|
||||
]), 'utf8'),
|
||||
});
|
||||
const eventId = await seedArchivedEvent(archiveRelPath, 'global-event');
|
||||
|
||||
const res = await request(app).post(`/admin/archives/${eventId}/restore`).send({});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const photo = await db('photos').where('filename', 'gl.jpg').first();
|
||||
expect(photo.category_id).toBe(globalId);
|
||||
// No clone, global or otherwise.
|
||||
const all = await db('photo_categories').where('name', 'Ceremony');
|
||||
expect(all).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('does not create a GLOBAL category when it has to invent one', async () => {
|
||||
// is_global defaults to true on this column, so an unqualified insert would
|
||||
// leak a restore's category name into every gallery on the instance.
|
||||
const archiveRelPath = await writeArchive('newcat.zip', {
|
||||
'individual/nc.jpg': PIXEL,
|
||||
'photos_manifest.json': Buffer.from(JSON.stringify([
|
||||
{ filename: 'nc.jpg', original_filename: 'DSC_2.jpg', category_name: 'Polterabend' },
|
||||
]), 'utf8'),
|
||||
});
|
||||
const eventId = await seedArchivedEvent(archiveRelPath, 'newcat-event');
|
||||
|
||||
const res = await request(app).post(`/admin/archives/${eventId}/restore`).send({});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const created = await db('photo_categories').where('name', 'Polterabend').first();
|
||||
expect(created.event_id).toBe(eventId);
|
||||
expect(created.is_global === false || created.is_global === 0).toBe(true);
|
||||
});
|
||||
|
||||
it('matches the manifest when the ZIP was written with original filenames', async () => {
|
||||
// With general_use_original_filenames_for_downloads on at archive time,
|
||||
// archiveService names entries after the ORIGINAL filename while the
|
||||
// manifest stays keyed by photos.filename. Looking up the extracted
|
||||
// basename missed every entry, so categories were lost on exactly those
|
||||
// archives.
|
||||
const archiveRelPath = await writeArchive('original-names.zip', {
|
||||
'individual/DSC_4242.jpg': PIXEL,
|
||||
'photos_manifest.json': Buffer.from(JSON.stringify([
|
||||
{ filename: 'stored_9f8e7d.jpg', original_filename: 'DSC_4242.jpg', category_name: 'Drohne' },
|
||||
]), 'utf8'),
|
||||
});
|
||||
const eventId = await seedArchivedEvent(archiveRelPath, 'original-names-event');
|
||||
|
||||
const res = await request(app).post(`/admin/archives/${eventId}/restore`).send({});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
expect(await categoryOf('DSC_4242.jpg')).toBe('Drohne');
|
||||
});
|
||||
|
||||
it('prefers the event-scoped category when a global shares its name', async () => {
|
||||
// The category API permits both. A single OR-lookup with .first() returned
|
||||
// whichever the engine chose, so a photo could be reassigned to the global
|
||||
// row and lose event-local settings such as allow_downloads.
|
||||
const archiveRelPath = await writeArchive('collide.zip', {
|
||||
'individual/co.jpg': PIXEL,
|
||||
'photos_manifest.json': Buffer.from(JSON.stringify([
|
||||
{ filename: 'co.jpg', original_filename: 'DSC_3.jpg', category_name: 'Reception' },
|
||||
]), 'utf8'),
|
||||
});
|
||||
const eventId = await seedArchivedEvent(archiveRelPath, 'collide-event');
|
||||
|
||||
await db('photo_categories').insert({
|
||||
event_id: null, name: 'Reception', slug: 'reception-global', is_global: true, created_at: new Date(),
|
||||
});
|
||||
const [own] = await db('photo_categories').insert({
|
||||
event_id: eventId, name: 'Reception', slug: 'reception-own', is_global: false, created_at: new Date(),
|
||||
}).returning('id');
|
||||
const ownId = typeof own === 'object' ? own.id : own;
|
||||
|
||||
const res = await request(app).post(`/admin/archives/${eventId}/restore`).send({});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const photo = await db('photos').where('filename', 'co.jpg').first();
|
||||
expect(photo.category_id).toBe(ownId);
|
||||
});
|
||||
|
||||
it('matches a sanitized original filename, as the ZIP would have written it', async () => {
|
||||
// archiveService runs original names through sanitizeForZipEntry() before
|
||||
// writing the entry, so the emitted name differs from the manifest column.
|
||||
const archiveRelPath = await writeArchive('sanitized.zip', {
|
||||
'individual/od_dr_DSC_5.jpg': PIXEL,
|
||||
'photos_manifest.json': Buffer.from(JSON.stringify([
|
||||
{ filename: 'stored_abc.jpg', original_filename: 'od/dr/DSC_5.jpg', category_name: 'Strand' },
|
||||
]), 'utf8'),
|
||||
});
|
||||
const eventId = await seedArchivedEvent(archiveRelPath, 'sanitized-event');
|
||||
|
||||
const res = await request(app).post(`/admin/archives/${eventId}/restore`).send({});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
expect(await categoryOf('od_dr_DSC_5.jpg')).toBe('Strand');
|
||||
});
|
||||
|
||||
it('ignores a legacy event-owned row when falling back to globals', async () => {
|
||||
// The bug fixed here left rows behind on upgraded instances: event-owned
|
||||
// AND is_global true, because the column defaults true. Matching on the
|
||||
// flag alone would let one event's leftover be adopted by another event's
|
||||
// restore, tying photos to a category that vanishes with someone else's
|
||||
// gallery.
|
||||
const otherEventId = await seedArchivedEvent('archives/none.zip', 'legacy-owner-event');
|
||||
await db('photo_categories').insert({
|
||||
event_id: otherEventId, name: 'Sunset', slug: 'sunset-legacy',
|
||||
is_global: true, created_at: new Date(),
|
||||
});
|
||||
|
||||
const archiveRelPath = await writeArchive('legacy-global.zip', {
|
||||
'individual/lg.jpg': PIXEL,
|
||||
'photos_manifest.json': Buffer.from(JSON.stringify([
|
||||
{ filename: 'lg.jpg', original_filename: 'DSC_6.jpg', category_name: 'Sunset' },
|
||||
]), 'utf8'),
|
||||
});
|
||||
const eventId = await seedArchivedEvent(archiveRelPath, 'legacy-global-event');
|
||||
|
||||
const res = await request(app).post(`/admin/archives/${eventId}/restore`).send({});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const photo = await db('photos').where('filename', 'lg.jpg').first();
|
||||
const cat = await db('photo_categories').where('id', photo.category_id).first();
|
||||
// Its own row, not the other event's leftover.
|
||||
expect(cat.event_id).toBe(eventId);
|
||||
});
|
||||
|
||||
it('drops an ambiguous original-name alias rather than guessing', async () => {
|
||||
// Two photos in different ZIP folders can share an original basename;
|
||||
// archiveService treats the paths as distinct and suffixes neither. Both
|
||||
// would collapse onto one alias, and whichever won would hand the other
|
||||
// photo someone else's category.
|
||||
const archiveRelPath = await writeArchive('ambiguous.zip', {
|
||||
'individual/SHARED.jpg': PIXEL,
|
||||
'photos_manifest.json': Buffer.from(JSON.stringify([
|
||||
{ filename: 'a_stored.jpg', original_filename: 'SHARED.jpg', category_name: 'Alpha' },
|
||||
{ filename: 'b_stored.jpg', original_filename: 'SHARED.jpg', category_name: 'Beta' },
|
||||
]), 'utf8'),
|
||||
});
|
||||
const eventId = await seedArchivedEvent(archiveRelPath, 'ambiguous-event');
|
||||
|
||||
const res = await request(app).post(`/admin/archives/${eventId}/restore`).send({});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
// Falls back to the directory rather than picking Alpha or Beta at random.
|
||||
expect(await categoryOf('SHARED.jpg')).toBe('individual');
|
||||
for (const name of ['Alpha', 'Beta']) {
|
||||
expect(await db('photo_categories').where({ event_id: eventId, name }).first()).toBeFalsy();
|
||||
}
|
||||
});
|
||||
|
||||
it('honours a manifest that says UNCATEGORIZED, instead of inventing one from the directory', async () => {
|
||||
// The case the manifest-first change was for. A real archive puts every
|
||||
// photo under `individual/`, so a photo the manifest records as having no
|
||||
// category used to come back filed under a category called "individual" —
|
||||
// the manifest being authoritative for "category X" but not for "none".
|
||||
const manifest = JSON.stringify([
|
||||
{ filename: 'u.jpg', original_filename: 'DSC_7000.jpg', category_name: null },
|
||||
]);
|
||||
const archiveRelPath = await writeArchive('uncategorized.zip', {
|
||||
'individual/u.jpg': PIXEL,
|
||||
'photos_manifest.json': Buffer.from(manifest, 'utf8'),
|
||||
});
|
||||
const eventId = await seedArchivedEvent(archiveRelPath, 'uncategorized-event');
|
||||
|
||||
const res = await request(app).post(`/admin/archives/${eventId}/restore`).send({});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
expect(await categoryOf('u.jpg')).toBeNull();
|
||||
// And no junk category row was created as a side effect.
|
||||
const rows = await db('photo_categories').where({ event_id: eventId });
|
||||
expect(rows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('drops a canonical filename that two photos claim, rather than guessing', async () => {
|
||||
// photos.filename is not unique within an event: s3AutoImporter takes
|
||||
// path.basename(entry.key) and dedupes by path, so two imported files in
|
||||
// different subfolders both land as IMG_1234.jpg. Both ZIP entries reduce
|
||||
// to the same basename at restore, so keeping the last row seen would give
|
||||
// one photo the other's category.
|
||||
const archiveRelPath = await writeArchive('dup-canonical.zip', {
|
||||
'individual/IMG_1234.jpg': PIXEL,
|
||||
'photos_manifest.json': Buffer.from(JSON.stringify([
|
||||
{ filename: 'IMG_1234.jpg', original_filename: 'a.jpg', category_name: 'Alpha' },
|
||||
{ filename: 'IMG_1234.jpg', original_filename: 'b.jpg', category_name: 'Beta' },
|
||||
]), 'utf8'),
|
||||
});
|
||||
const eventId = await seedArchivedEvent(archiveRelPath, 'dup-canonical-event');
|
||||
|
||||
const res = await request(app).post(`/admin/archives/${eventId}/restore`).send({});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
expect(await categoryOf('IMG_1234.jpg')).toBe('individual');
|
||||
for (const name of ['Alpha', 'Beta']) {
|
||||
expect(await db('photo_categories').where({ event_id: eventId, name }).first()).toBeFalsy();
|
||||
}
|
||||
});
|
||||
|
||||
it("drops a name that one row owns canonically and another claims as an alias", async () => {
|
||||
// Undecidable: with original-filename archiving ON the ZIP entry under
|
||||
// this name is the ALIAS owner's file, with it OFF it is the canonical
|
||||
// owner's, and the manifest does not record which mode was used. The
|
||||
// point of the two-pass split is that this now resolves the same way
|
||||
// every run — the archive query has no ORDER BY, so it used to be a coin
|
||||
// flip between dropping the name and overwriting it.
|
||||
const archiveRelPath = await writeArchive('alias-vs-canonical.zip', {
|
||||
'individual/CANON.jpg': PIXEL,
|
||||
'photos_manifest.json': Buffer.from(JSON.stringify([
|
||||
{ filename: 'CANON.jpg', original_filename: 'unrelated.jpg', category_name: 'Canonical' },
|
||||
{ filename: 'other_stored.jpg', original_filename: 'CANON.jpg', category_name: 'Aliased' },
|
||||
]), 'utf8'),
|
||||
});
|
||||
const eventId = await seedArchivedEvent(archiveRelPath, 'alias-vs-canonical-event');
|
||||
|
||||
const res = await request(app).post(`/admin/archives/${eventId}/restore`).send({});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
// Falls back to the directory rather than guessing either row.
|
||||
expect(await categoryOf('CANON.jpg')).toBe('individual');
|
||||
for (const name of ['Canonical', 'Aliased']) {
|
||||
expect(await db('photo_categories').where({ event_id: eventId, name }).first()).toBeFalsy();
|
||||
}
|
||||
});
|
||||
|
||||
it('picks the lowest id and warns when two categories share a name', async () => {
|
||||
// Allowed: two event-scoped categories with the same display name and
|
||||
// different slugs. .first() used to pick either, so a re-run could move
|
||||
// photos between them and inherit the wrong allow_downloads.
|
||||
const archiveRelPath = await writeArchive('dupe-category.zip', {
|
||||
'individual/DUPE.jpg': PIXEL,
|
||||
'photos_manifest.json': Buffer.from(JSON.stringify([
|
||||
{ filename: 'DUPE.jpg', original_filename: 'DUPE.jpg', category_name: 'Ceremony' },
|
||||
]), 'utf8'),
|
||||
});
|
||||
const eventId = await seedArchivedEvent(archiveRelPath, 'dupe-category-event');
|
||||
|
||||
const [first] = await db('photo_categories').insert({
|
||||
name: 'Ceremony', slug: 'ceremony-a', is_global: 0, event_id: eventId,
|
||||
}).returning('id');
|
||||
await db('photo_categories').insert({
|
||||
name: 'Ceremony', slug: 'ceremony-b', is_global: 0, event_id: eventId,
|
||||
});
|
||||
const firstId = typeof first === 'object' ? first.id : first;
|
||||
|
||||
const res = await request(app).post(`/admin/archives/${eventId}/restore`).send({});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
// Stable, not arbitrary: the same run twice lands on the same row.
|
||||
const photo = await db('photos').where({ event_id: eventId, filename: 'DUPE.jpg' }).first();
|
||||
expect(photo.category_id).toBe(firstId);
|
||||
// And no third "Ceremony" was invented.
|
||||
expect((await db('photo_categories').where({ event_id: eventId, name: 'Ceremony' })).length)
|
||||
.toBe(2);
|
||||
});
|
||||
|
||||
it('does not invent a category for a photo row that already exists', async () => {
|
||||
// archiveEvent retains photo rows, so a restore can skip every insert.
|
||||
// Resolving categories before that check created one from the stale
|
||||
// manifest name that nothing then used — renaming a category while its
|
||||
// event was archived left the old name behind as an empty duplicate.
|
||||
const archiveRelPath = await writeArchive('existing-rows.zip', {
|
||||
'individual/KEPT.jpg': PIXEL,
|
||||
'photos_manifest.json': Buffer.from(JSON.stringify([
|
||||
{ filename: 'KEPT.jpg', original_filename: 'KEPT.jpg', category_name: 'OldName' },
|
||||
]), 'utf8'),
|
||||
});
|
||||
const eventId = await seedArchivedEvent(archiveRelPath, 'existing-rows-event');
|
||||
await db('photos').insert({
|
||||
event_id: eventId, filename: 'KEPT.jpg', path: 'whatever/KEPT.jpg', type: 'jpg',
|
||||
uploaded_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const res = await request(app).post(`/admin/archives/${eventId}/restore`).send({});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
expect(await db('photo_categories').where({ event_id: eventId, name: 'OldName' }).first())
|
||||
.toBeFalsy();
|
||||
});
|
||||
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* POST /api/admin/business-profile/logo and PUT /api/admin/business-profile
|
||||
* — GHSA-6wrv-9pr4-hhmw regression coverage.
|
||||
*
|
||||
* The upload route used to take the stored file extension straight from
|
||||
* the client-supplied filename and only checked `file.mimetype` against an
|
||||
* allowlist — a file could declare an image MIME type while carrying a
|
||||
* `.html`/`.js` extension and arbitrary content, land in the same-origin
|
||||
* `/uploads/logos` static mount, and execute as script. The mass-assignable
|
||||
* `logoPath` field on PUT compounded it: an attacker could point the
|
||||
* "logo" at any other uploaded file.
|
||||
*
|
||||
* These tests pin:
|
||||
* (a) a MIME/extension mismatch is rejected at upload,
|
||||
* (b) the extension actually written to disk always matches the
|
||||
* validated MIME type, never the client-supplied filename,
|
||||
* (c) legitimate PNG/JPEG/SVG uploads still succeed,
|
||||
* (d) `logoPath` on PUT cannot be set to an arbitrary string pointing at
|
||||
* another file, only to a path the upload route itself produced.
|
||||
*
|
||||
* Defense-in-depth (not a re-opening of the above): fileFilter only pairs
|
||||
* the claimed MIME type against the extension — it can't see the bytes,
|
||||
* since it runs before multer finishes writing the stream to disk. A file
|
||||
* whose declared MIME/extension pair is valid but whose actual content
|
||||
* doesn't match (e.g. a PNG-declared upload that isn't really a PNG) is
|
||||
* now caught by validateFileContent() (magic-number check) after multer
|
||||
* writes it, closing the gap where declared-vs-actual content diverges.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-bplogo-test-'));
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'db.sqlite');
|
||||
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
|
||||
fs.mkdirSync(process.env.STORAGE_PATH, { recursive: true });
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'bplogo-route-test-secret';
|
||||
|
||||
const request = require('supertest');
|
||||
const {
|
||||
bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken, buildRouteApp,
|
||||
} = require('./helpers/crmDb');
|
||||
|
||||
// Real magic-number-prefixed payloads, for content-sniffing to accept.
|
||||
const REAL_PNG_BYTES = Buffer.concat([
|
||||
Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]),
|
||||
Buffer.from('not a real png body, but the header is real'),
|
||||
]);
|
||||
const REAL_JPEG_BYTES = Buffer.concat([
|
||||
Buffer.from([0xFF, 0xD8, 0xFF]),
|
||||
Buffer.from('not a real jpeg body, but the header is real'),
|
||||
]);
|
||||
|
||||
describe('business profile — logo upload content/extension validation', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
let app;
|
||||
let token;
|
||||
|
||||
const uploadLogo = (buffer, filename, mimetype) => request(app)
|
||||
.post('/api/admin/business-profile/logo')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.attach('logo', buffer, { filename, contentType: mimetype });
|
||||
|
||||
const put = (payload) => request(app)
|
||||
.put('/api/admin/business-profile')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send(payload);
|
||||
|
||||
const get = () => request(app)
|
||||
.get('/api/admin/business-profile')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
|
||||
const profileOf = (res) => (res.body.data || res.body).profile;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
const { adminId } = await seedMinimal(db);
|
||||
await assignAdminRole(db, adminId, 'super_admin');
|
||||
token = mintAdminToken(adminId);
|
||||
app = buildRouteApp('/api/admin/business-profile', require('../../src/routes/adminBusinessProfile'));
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
// fileFilter rejections surface via Express's generic error handler
|
||||
// (the pre-existing behaviour of every sibling logo/favicon upload
|
||||
// route in this codebase — none of them special-case multer's
|
||||
// fileFilter `Error` into a 400 either), so the status code itself
|
||||
// can be 400 or 500 depending on environment. What actually matters
|
||||
// for GHSA-6wrv-9pr4-hhmw is that the request never succeeds and
|
||||
// nothing with the dangerous extension is ever written to disk.
|
||||
const logosDirFiles = () => {
|
||||
const logosDir = path.join(process.env.STORAGE_PATH, 'uploads', 'logos');
|
||||
return fs.existsSync(logosDir) ? fs.readdirSync(logosDir) : [];
|
||||
};
|
||||
|
||||
it('rejects an HTML/script payload disguised as an image via mismatched extension', async () => {
|
||||
const evil = Buffer.from('<script>alert(document.domain)</script>');
|
||||
const res = await uploadLogo(evil, 'evil.html', 'image/svg+xml');
|
||||
expect(res.status).not.toBe(200);
|
||||
expect(logosDirFiles().some((f) => f.endsWith('.html'))).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a .js file disguised with an image MIME type', async () => {
|
||||
const evil = Buffer.from('alert(1)');
|
||||
const res = await uploadLogo(evil, 'evil.js', 'image/png');
|
||||
expect(res.status).not.toBe(200);
|
||||
expect(logosDirFiles().some((f) => f.endsWith('.js'))).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a disallowed MIME type outright', async () => {
|
||||
const res = await uploadLogo(Buffer.from('whatever'), 'file.pdf', 'application/pdf');
|
||||
expect(res.status).not.toBe(200);
|
||||
expect(logosDirFiles().some((f) => f.endsWith('.pdf'))).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts a legitimate PNG upload and stores it with a .png extension', async () => {
|
||||
const res = await uploadLogo(REAL_PNG_BYTES, 'logo.png', 'image/png');
|
||||
expect(res.status).toBe(200);
|
||||
const logoPath = (res.body.data || res.body).logoPath;
|
||||
expect(logoPath).toMatch(/^\/uploads\/logos\/pdf-logo-\d+\.png$/);
|
||||
|
||||
const onDisk = path.join(process.env.STORAGE_PATH, logoPath.replace(/^\//, ''));
|
||||
expect(fs.existsSync(onDisk)).toBe(true);
|
||||
|
||||
expect(profileOf(await get()).logoPath).toBe(logoPath);
|
||||
});
|
||||
|
||||
it('accepts a legitimate JPEG upload and stores it with a .jpg extension', async () => {
|
||||
const res = await uploadLogo(REAL_JPEG_BYTES, 'logo.jpg', 'image/jpeg');
|
||||
expect(res.status).toBe(200);
|
||||
const logoPath = (res.body.data || res.body).logoPath;
|
||||
expect(logoPath).toMatch(/^\/uploads\/logos\/pdf-logo-\d+\.jpg$/);
|
||||
});
|
||||
|
||||
it('rejects a PNG-declared upload whose bytes are not actually a PNG, and leaves nothing on disk', async () => {
|
||||
const before = logosDirFiles();
|
||||
const res = await uploadLogo(Buffer.from('totally not a png'), 'logo.png', 'image/png');
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/content does not match/i);
|
||||
|
||||
// No new file left behind: the rejected upload's own file was cleaned
|
||||
// up, and every other file on disk (if any) is unchanged.
|
||||
expect(logosDirFiles()).toEqual(before);
|
||||
});
|
||||
|
||||
it('accepts a legitimate SVG upload and always stores it with a .svg extension, even under a spoofed filename', async () => {
|
||||
const svg = Buffer.from('<svg xmlns="http://www.w3.org/2000/svg"><rect width="1" height="1"/></svg>');
|
||||
// Client-declared filename ext is .svg here to pass validateFileType
|
||||
// (mismatched ext is covered by the rejection tests above); the point
|
||||
// of this test is that the ON-DISK extension comes from the MIME type
|
||||
// lookup table, not path.extname(originalname).
|
||||
const res = await uploadLogo(svg, 'vector-logo.svg', 'image/svg+xml');
|
||||
expect(res.status).toBe(200);
|
||||
const logoPath = (res.body.data || res.body).logoPath;
|
||||
expect(logoPath).toMatch(/^\/uploads\/logos\/pdf-logo-\d+\.svg$/);
|
||||
});
|
||||
|
||||
it('rejects logoPath on PUT set to an arbitrary string pointing at another file', async () => {
|
||||
const before = profileOf(await get()).logoPath;
|
||||
|
||||
const res = await put({ logoPath: '/uploads/logos/cms-somepage-1234.png' });
|
||||
expect(res.status).toBe(400);
|
||||
|
||||
expect(profileOf(await get()).logoPath).toBe(before);
|
||||
});
|
||||
|
||||
it('rejects logoPath on PUT with a path-traversal payload', async () => {
|
||||
const res = await put({ logoPath: '/uploads/logos/../../../../etc/passwd' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('accepts logoPath on PUT when it matches the pattern this route itself writes', async () => {
|
||||
const upload = await uploadLogo(REAL_PNG_BYTES, 'logo2.png', 'image/png');
|
||||
const uploadedPath = (upload.body.data || upload.body).logoPath;
|
||||
|
||||
// Round-trip: PUT-ing back the exact value the upload endpoint
|
||||
// returned (what the frontend's generic profile save does) must
|
||||
// keep working.
|
||||
const res = await put({ logoPath: uploadedPath });
|
||||
expect(res.status).toBe(200);
|
||||
expect(profileOf(await get()).logoPath).toBe(uploadedPath);
|
||||
});
|
||||
|
||||
it('still allows clearing logoPath with an empty string', async () => {
|
||||
const res = await put({ logoPath: '' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(profileOf(await get()).logoPath).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Publishing must not be a way around the configured gallery password policy.
|
||||
*
|
||||
* `POST /:id/publish` (#627) re-hashes `password_hash` from a plaintext the
|
||||
* admin re-types in the publish dialog, and validated it with nothing but
|
||||
* express-validator's `isLength({ min: 6 })`. So the configured complexity —
|
||||
* moderate by default, meaning 8 characters plus upper, lower and a digit —
|
||||
* governed event creation and password reset, while this door accepted
|
||||
* `aaaaaa` and made it the live gallery password.
|
||||
*
|
||||
* Not an escalation: it needs admin auth plus events.edit, and such an admin
|
||||
* could already set a weak password elsewhere. It is a policy gap — the admin
|
||||
* UI advertises a complexity level this write path did not enforce.
|
||||
*/
|
||||
|
||||
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-pubpolicy-')), 'db.sqlite',
|
||||
);
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'publish-policy-test-secret';
|
||||
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-pubpolicy-storage-'));
|
||||
|
||||
const request = require('supertest');
|
||||
const {
|
||||
bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken, buildRouteApp,
|
||||
} = require('./helpers/crmDb');
|
||||
|
||||
describe('publish enforces the gallery password policy', () => {
|
||||
let db; let cleanup; let app; let token;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
const { adminId } = await seedMinimal(db);
|
||||
await assignAdminRole(db, adminId, 'admin');
|
||||
token = mintAdminToken(adminId);
|
||||
app = buildRouteApp('/admin/events', require('../../src/routes/adminEvents'));
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
async function seedDraft(slug) {
|
||||
const [row] = await db('events').insert({
|
||||
slug,
|
||||
event_type: 'wedding',
|
||||
event_name: `Event ${slug}`,
|
||||
event_date: '2026-09-01',
|
||||
host_email: 'client@example.com',
|
||||
admin_email: 'admin@example.com',
|
||||
password_hash: 'original-hash',
|
||||
require_password: 1,
|
||||
share_link: `/gallery/${slug}/share`,
|
||||
share_token: `${slug}-token`,
|
||||
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
|
||||
is_active: 1,
|
||||
is_archived: 0,
|
||||
is_draft: 1,
|
||||
created_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
return typeof row === 'object' ? row.id : row;
|
||||
}
|
||||
|
||||
it('refuses a password that misses the configured complexity', async () => {
|
||||
const id = await seedDraft('weak-publish');
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/admin/events/${id}/publish`)
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ password: 'aaaaaa' });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/security requirements/i);
|
||||
|
||||
// Rejected BEFORE the write, not after — the gallery must be untouched,
|
||||
// and still a draft.
|
||||
const after = await db('events').where({ id }).first();
|
||||
expect(after.password_hash).toBe('original-hash');
|
||||
expect(after.is_draft === 1 || after.is_draft === true).toBe(true);
|
||||
});
|
||||
|
||||
it('still accepts a password that meets it', async () => {
|
||||
const id = await seedDraft('strong-publish');
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/admin/events/${id}/publish`)
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ password: 'Sup3r-Secret' });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const bcrypt = require('bcrypt');
|
||||
const after = await db('events').where({ id }).first();
|
||||
expect(after.password_hash).not.toBe('original-hash');
|
||||
expect(await bcrypt.compare('Sup3r-Secret', after.password_hash)).toBe(true);
|
||||
});
|
||||
|
||||
it('leaves a publish without a password alone', async () => {
|
||||
// The legacy sentinel path: no password in the body means no rehash, so
|
||||
// the policy has nothing to check and must not block the publish.
|
||||
const id = await seedDraft('no-password-publish');
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/admin/events/${id}/publish`)
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const after = await db('events').where({ id }).first();
|
||||
expect(after.password_hash).toBe('original-hash');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* GHSA-h4w8-57xq-53fx enforcement half: `must_change_password` was written
|
||||
* by the admin password-reset flow (userManagementService.resetAdminPassword)
|
||||
* and returned in a few response payloads, but no route-blocking logic ever
|
||||
* checked it — a reset admin could keep using the old/weak password on every
|
||||
* protected route indefinitely. adminAuth() is now the server-side backstop:
|
||||
* a flagged admin gets 403 MUST_CHANGE_PASSWORD on everything except the
|
||||
* routes they need to clear the flag (change-password) or leave (logout).
|
||||
*
|
||||
* Mirrors the mocking shape of adminAuthRoleFallback.test.js — a stub `db`
|
||||
* chain, no real SQLite needed, so this stays a fast unit test.
|
||||
*/
|
||||
|
||||
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() }));
|
||||
|
||||
let mockMustChangePassword = false;
|
||||
const mockAdminRow = { id: 7, username: 'scoped', email: 's@example.com', password_changed_at: null, role_id: 1, role_name: 'editor' };
|
||||
|
||||
jest.mock('../../src/database/db', () => ({
|
||||
db: () => ({
|
||||
leftJoin() { return this; },
|
||||
where() { return this; },
|
||||
select() { return this; },
|
||||
first: () => Promise.resolve({ ...mockAdminRow, must_change_password: mockMustChangePassword }),
|
||||
}),
|
||||
}));
|
||||
|
||||
const { adminAuth } = require('../../src/middleware/auth');
|
||||
|
||||
const SECRET = 'test-secret-for-must-change-password';
|
||||
|
||||
function makeReq(originalUrl) {
|
||||
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: {}, originalUrl };
|
||||
}
|
||||
|
||||
function makeRes() {
|
||||
return {
|
||||
statusCode: null,
|
||||
body: null,
|
||||
status(code) { this.statusCode = code; return this; },
|
||||
json(payload) { this.body = payload; return this; },
|
||||
};
|
||||
}
|
||||
|
||||
describe('adminAuth must_change_password enforcement (GHSA-h4w8-57xq-53fx)', () => {
|
||||
const OLD_SECRET = process.env.JWT_SECRET;
|
||||
beforeAll(() => { process.env.JWT_SECRET = SECRET; });
|
||||
afterAll(() => { process.env.JWT_SECRET = OLD_SECRET; });
|
||||
beforeEach(() => { mockMustChangePassword = false; });
|
||||
|
||||
it('blocks an arbitrary protected route with 403 MUST_CHANGE_PASSWORD when the flag is set', async () => {
|
||||
mockMustChangePassword = true;
|
||||
const req = makeReq('/api/admin/dashboard/stats');
|
||||
const res = makeRes();
|
||||
const next = jest.fn();
|
||||
|
||||
await adminAuth(req, res, next);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(res.body).toEqual(expect.objectContaining({ code: 'MUST_CHANGE_PASSWORD' }));
|
||||
expect(req.admin).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not block when the flag is not set', async () => {
|
||||
mockMustChangePassword = false;
|
||||
const req = makeReq('/api/admin/dashboard/stats');
|
||||
const res = makeRes();
|
||||
const next = jest.fn();
|
||||
|
||||
await adminAuth(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(req.admin.mustChangePassword).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['/api/admin/auth/change-password'],
|
||||
['/api/admin/auth/logout'],
|
||||
])('still allows %s through when the flag is set', async (originalUrl) => {
|
||||
mockMustChangePassword = true;
|
||||
const req = makeReq(originalUrl);
|
||||
const res = makeRes();
|
||||
const next = jest.fn();
|
||||
|
||||
await adminAuth(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(req.admin.mustChangePassword).toBe(true);
|
||||
expect(res.statusCode).toBeNull();
|
||||
});
|
||||
|
||||
it('allows the exempt change-password path even with a query string', async () => {
|
||||
mockMustChangePassword = true;
|
||||
const req = makeReq('/api/admin/auth/change-password?foo=bar');
|
||||
const res = makeRes();
|
||||
const next = jest.fn();
|
||||
|
||||
await adminAuth(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not exempt a route that merely starts with the change-password path', async () => {
|
||||
mockMustChangePassword = true;
|
||||
const req = makeReq('/api/admin/auth/change-password-history');
|
||||
const res = makeRes();
|
||||
const next = jest.fn();
|
||||
|
||||
await adminAuth(req, res, next);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
});
|
||||
@@ -1,103 +0,0 @@
|
||||
/**
|
||||
* Regression test for the cross-event thumbnail enumeration leak.
|
||||
*
|
||||
* Thumbnails are served flat from /thumbnails/thumb_<name> with
|
||||
* deterministic, enumerable filenames. photoAuth previously granted any
|
||||
* holder of a gallery token for ANY active event access to ANY thumbnail
|
||||
* (it set eventSlug=null and returned next() as long as the token's event
|
||||
* existed), so a visitor to one gallery could pull another (password-
|
||||
* protected) gallery's entire thumbnail set. The fix scopes thumbnail
|
||||
* access to the token's event by matching the requested file against
|
||||
* photos.thumbnail_path for that event_id.
|
||||
*/
|
||||
|
||||
process.env.JWT_SECRET = 'test-secret-thumbnail-scope-000000000000';
|
||||
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
// Two events, each owning one thumbnail. The photos mock resolves a row
|
||||
// only when BOTH event_id and thumbnail_path match — i.e. it models the
|
||||
// real ownership query.
|
||||
const EVENTS = [
|
||||
{ id: 10, slug: 'event-a', is_active: 1 },
|
||||
{ id: 20, slug: 'event-b', is_active: 1 },
|
||||
];
|
||||
const PHOTOS = [
|
||||
{ id: 1, event_id: 10, thumbnail_path: 'thumbnails/thumb_event-a_ceremony_0001.jpg' },
|
||||
{ id: 2, event_id: 20, thumbnail_path: 'thumbnails/thumb_event-b_ceremony_0001.jpg' },
|
||||
];
|
||||
|
||||
jest.mock('../../src/database/db', () => ({
|
||||
db: (table) => ({
|
||||
_cond: null,
|
||||
where(cond) { this._cond = cond; return this; },
|
||||
first() {
|
||||
if (table === 'events') {
|
||||
return Promise.resolve(EVENTS.find((e) => e.id === this._cond.id) || null);
|
||||
}
|
||||
if (table === 'photos') {
|
||||
return Promise.resolve(
|
||||
PHOTOS.find((p) => p.event_id === this._cond.event_id
|
||||
&& p.thumbnail_path === this._cond.thumbnail_path) || null
|
||||
);
|
||||
}
|
||||
return Promise.resolve(null);
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/utils/logger', () => ({
|
||||
info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn(),
|
||||
}));
|
||||
|
||||
const photoAuth = require('../../src/middleware/photoAuth');
|
||||
|
||||
function galleryToken(eventId) {
|
||||
return jwt.sign({ type: 'gallery', eventId }, process.env.JWT_SECRET, { issuer: 'picpeak-auth' });
|
||||
}
|
||||
|
||||
function makeReqRes(token, thumbPath) {
|
||||
const req = { path: thumbPath, headers: { authorization: `Bearer ${token}` }, cookies: {} };
|
||||
const res = {
|
||||
statusCode: null,
|
||||
body: null,
|
||||
status(code) { this.statusCode = code; return this; },
|
||||
json(payload) { this.body = payload; return this; },
|
||||
};
|
||||
return { req, res };
|
||||
}
|
||||
|
||||
describe('photoAuth — thumbnail ownership scoping', () => {
|
||||
it('denies a gallery token for event A fetching event B\'s thumbnail', async () => {
|
||||
const { req, res } = makeReqRes(galleryToken(10), '/thumb_event-b_ceremony_0001.jpg');
|
||||
const next = jest.fn();
|
||||
|
||||
await photoAuth(req, res, next);
|
||||
|
||||
// Access denied: middleware must not pass the request through.
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res.statusCode).toBeGreaterThanOrEqual(400);
|
||||
expect(req.event).toBeUndefined();
|
||||
});
|
||||
|
||||
it('allows a gallery token to fetch its own event\'s thumbnail', async () => {
|
||||
const { req, res } = makeReqRes(galleryToken(20), '/thumb_event-b_ceremony_0001.jpg');
|
||||
const next = jest.fn();
|
||||
|
||||
await photoAuth(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(req.event).toMatchObject({ id: 20 });
|
||||
});
|
||||
|
||||
it('denies a traversal / foreign filename that matches no owned thumbnail', async () => {
|
||||
const { req, res } = makeReqRes(galleryToken(10), '/thumb_../../etc/passwd');
|
||||
const next = jest.fn();
|
||||
|
||||
await photoAuth(req, res, next);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res.statusCode).toBeGreaterThanOrEqual(400);
|
||||
expect(req.event).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Second security sweep on the same branch as the password-strength DoS fix
|
||||
* (stable port: the maintenance and admin-preview cases do not apply here).
|
||||
* Each block pins one gap the audit found:
|
||||
*
|
||||
* - the general rate limiter skipped anyone holding ANY verified JWT,
|
||||
* including a gallery token minted for free on password-less galleries
|
||||
* - the multipart branch of the CSRF Content-Type gate accepted cross-site
|
||||
* form posts
|
||||
*/
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
process.env.JWT_SECRET = 'hardening-batch2-secret';
|
||||
|
||||
const fake = { maintenance: 'true', revoked: false, beforeCutoff: false, admin: { id: 1, password_changed_at: null } };
|
||||
|
||||
jest.mock('../../src/database/db', () => {
|
||||
const db = jest.fn((table) => {
|
||||
const q = {
|
||||
where: jest.fn().mockReturnThis(),
|
||||
select: jest.fn().mockReturnThis(),
|
||||
first: jest.fn(async () => {
|
||||
if (table === 'app_settings') {
|
||||
return { setting_key: 'general_maintenance_mode', setting_value: fake.maintenance };
|
||||
}
|
||||
if (table === 'admin_users') return fake.admin;
|
||||
return null;
|
||||
}),
|
||||
};
|
||||
return q;
|
||||
});
|
||||
return { db, withRetry: (fn) => fn() };
|
||||
});
|
||||
jest.mock('../../src/utils/logger', () => ({ error: jest.fn(), warn: jest.fn(), info: jest.fn(), debug: jest.fn() }));
|
||||
process.env.FRONTEND_URL = 'https://photos.example.com';
|
||||
|
||||
const { isAuthenticated } = require('../../src/services/rateLimitService');
|
||||
const { multipartOriginAllowed } = require('../../src/utils/requestOrigin');
|
||||
|
||||
const iat = Math.floor(Date.now() / 1000) - 10;
|
||||
const adminToken = (extra = {}) => jwt.sign({ type: 'admin', id: 1, iat, ...extra }, process.env.JWT_SECRET, { issuer: 'picpeak-auth' });
|
||||
const galleryToken = () => jwt.sign({ type: 'gallery', eventId: 1, iat }, process.env.JWT_SECRET, { issuer: 'picpeak-auth' });
|
||||
|
||||
describe('general rate limiter skip', () => {
|
||||
const req = (token) => ({ path: '/api/gallery/x/photos', headers: { authorization: `Bearer ${token}` }, cookies: {} });
|
||||
it('is granted to an admin session', () => {
|
||||
expect(isAuthenticated(req(adminToken()))).toBe(true);
|
||||
});
|
||||
it('is NOT granted to a gallery token', () => {
|
||||
expect(isAuthenticated(req(galleryToken()))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('multipart origin gate', () => {
|
||||
const req = (headers) => ({ headers: { host: 'photos.example.com', ...headers } });
|
||||
it('accepts same-origin, same-site and non-browser requests', () => {
|
||||
expect(multipartOriginAllowed(req({ 'sec-fetch-site': 'same-origin' }))).toBe(true);
|
||||
expect(multipartOriginAllowed(req({ 'sec-fetch-site': 'same-site' }))).toBe(true);
|
||||
expect(multipartOriginAllowed(req({ 'sec-fetch-site': 'none' }))).toBe(true);
|
||||
expect(multipartOriginAllowed(req({}))).toBe(true);
|
||||
expect(multipartOriginAllowed(req({ origin: 'https://photos.example.com' }))).toBe(true);
|
||||
// Same-origin install without FRONTEND_URL: Origin matches the Host.
|
||||
expect(multipartOriginAllowed({ headers: { host: 'gallery.local', origin: 'http://gallery.local' } })).toBe(true);
|
||||
});
|
||||
it('rejects cross-site form posts', () => {
|
||||
expect(multipartOriginAllowed(req({ 'sec-fetch-site': 'cross-site' }))).toBe(false);
|
||||
expect(multipartOriginAllowed(req({ origin: 'https://evil.example' }))).toBe(false);
|
||||
expect(multipartOriginAllowed(req({ origin: 'null' }))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Same bug class as GHSA-9q5j-vqfw-32hr (fixed in adminEvents/logo.js) —
|
||||
* the signed-PDF upload's multer `filename` callback built the stored
|
||||
* path directly from `req.params.id` with no integer validation:
|
||||
*
|
||||
* filename: (req, file, cb) => {
|
||||
* cb(null, `contract-${req.params.id}-${Date.now()}${ext}`);
|
||||
* }
|
||||
*
|
||||
* `POST /:id/upload-signed-pdf` declares `param('id').isInt({ min: 1 })`,
|
||||
* but express-validator's check only runs inside the route handler via
|
||||
* validateRequest(req) — AFTER multer has already parsed the multipart
|
||||
* body and invoked the filename callback. A traversal payload in the raw
|
||||
* `:id` URL segment reaches multer completely unvalidated.
|
||||
*
|
||||
* Fixed by rejecting any non-positive-integer id before it is used to
|
||||
* build the filename, independent of the declared-but-too-late
|
||||
* express-validator check.
|
||||
*/
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
// ALLOWED_MEDIA_TYPES in fileSecurityUtils.js only defines image/video
|
||||
// entries, so the route's real fileFilter (validateFileType(..., ['application/pdf']))
|
||||
// rejects every PDF upload with "Only PDF files are allowed" — a
|
||||
// separate, pre-existing bug unrelated to the path-traversal fix under
|
||||
// test here (also present in publicContracts.js, which is why neither
|
||||
// suite exercises a successful upload). Stub validateFileType so this
|
||||
// suite can drive the full route, including the filename-callback fix,
|
||||
// end-to-end.
|
||||
jest.mock('../../src/utils/fileSecurityUtils', () => {
|
||||
const actual = jest.requireActual('../../src/utils/fileSecurityUtils');
|
||||
return {
|
||||
...actual,
|
||||
validateFileType: (filename, mimetype, allowedTypes) => allowedTypes.includes(mimetype),
|
||||
};
|
||||
});
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-contracts-signed-pdf-')), 'db.sqlite'
|
||||
);
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'admin-contracts-signed-pdf-test-secret';
|
||||
|
||||
const request = require('supertest');
|
||||
const {
|
||||
bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken, buildRouteApp,
|
||||
} = require('../integration/helpers/crmDb');
|
||||
|
||||
describe('POST /api/admin/contracts/:id/upload-signed-pdf — path traversal guard', () => {
|
||||
let db; let cleanup; let app; let adminId; let customerId; let token;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
({ adminId, customerId } = await seedMinimal(db));
|
||||
await assignAdminRole(db, adminId, 'super_admin');
|
||||
token = mintAdminToken(adminId);
|
||||
|
||||
// Feature flag defaults OFF on a fresh install — the contracts
|
||||
// router 403s every route until it's on.
|
||||
await db('feature_flags').where({ key: 'contracts' }).update({ value: true });
|
||||
|
||||
app = buildRouteApp('/api/admin/contracts', require('../../src/routes/adminContracts'));
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { await cleanup(); });
|
||||
|
||||
const auth = (req) => req.set('Authorization', `Bearer ${token}`);
|
||||
const signedDir = () => path.join(process.env.STORAGE_PATH, 'uploads/contracts/signed');
|
||||
|
||||
async function insertContract(over = {}) {
|
||||
const base = {
|
||||
contract_number: `K-TEST-${Math.random().toString(16).slice(2, 8)}`,
|
||||
customer_account_id: customerId,
|
||||
title: 'Test Contract',
|
||||
issue_date: new Date().toISOString().slice(0, 10),
|
||||
status: 'sent',
|
||||
language: 'de',
|
||||
created_at: new Date().toISOString(),
|
||||
...over,
|
||||
};
|
||||
const inserted = await db('contracts').insert(base).returning('id');
|
||||
return inserted[0]?.id ?? inserted[0];
|
||||
}
|
||||
|
||||
it('rejects a traversal payload in the id param instead of writing outside uploads/contracts/signed', async () => {
|
||||
// '../../../../tmp/pwned' URL-encoded so the raw request path still
|
||||
// has a single segment (matches Express's `:id`), but Express
|
||||
// decodes the param back into literal '../' sequences before the
|
||||
// route sees it.
|
||||
const traversalId = encodeURIComponent('../../../../tmp/pwned');
|
||||
|
||||
const res = await auth(
|
||||
request(app).post(`/api/admin/contracts/${traversalId}/upload-signed-pdf`)
|
||||
).attach('file', Buffer.from('%PDF-1.4 fake'), 'signed.pdf');
|
||||
|
||||
expect(res.status).toBeGreaterThanOrEqual(400);
|
||||
expect(res.body.error).toMatch(/invalid contract id/i);
|
||||
|
||||
// No file should have been written anywhere — the filename callback
|
||||
// must error out before multer opens a write stream.
|
||||
const escapedFile = path.join(os.tmpdir(), 'pwned');
|
||||
expect(fs.existsSync(escapedFile)).toBe(false);
|
||||
if (fs.existsSync(signedDir())) {
|
||||
expect(fs.readdirSync(signedDir())).toHaveLength(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('still accepts a normal numeric contract id', async () => {
|
||||
const id = await insertContract();
|
||||
|
||||
const res = await auth(
|
||||
request(app).post(`/api/admin/contracts/${id}/upload-signed-pdf`)
|
||||
).attach('file', Buffer.from('%PDF-1.4 fake'), 'signed.pdf');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const files = fs.readdirSync(signedDir());
|
||||
expect(files.some((f) => f.startsWith(`contract-${id}-`))).toBe(true);
|
||||
|
||||
const row = await db('contracts').where({ id }).first();
|
||||
expect(row.status).toBe('fully_signed');
|
||||
expect(row.signed_pdf_path).toMatch(new RegExp(`contract-${id}-`));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* Regression test: deleting an event must remove its stored objects.
|
||||
*
|
||||
* deleteEventCascade() cleaned up the local filesystem only (#608). On an
|
||||
* S3/R2 storage backend that cleanup is a no-op, so every deleted gallery
|
||||
* left its originals and derived tiers in the bucket — unreferenced,
|
||||
* invisible in the UI, and billed forever. Measured on a v3.45.16 install
|
||||
* against Cloudflare R2: deleting a 403-photo event changed the bucket
|
||||
* object count by exactly zero.
|
||||
*
|
||||
* The keys must be collected BEFORE the transaction deletes the photo
|
||||
* rows, because afterwards nothing knows which objects were this event's.
|
||||
*/
|
||||
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
// The cascade runs a real `fs.rm(..., { recursive: true })` over
|
||||
// {STORAGE_PATH}/events/{active,archived}/{slug}. Point that at a throwaway
|
||||
// directory before requiring the module under test — the default resolves
|
||||
// into the working tree.
|
||||
process.env.STORAGE_PATH = path.join(os.tmpdir(), 'picpeak-cascade-storage-test');
|
||||
|
||||
const mockStorage = { delete: jest.fn().mockResolvedValue(undefined) };
|
||||
|
||||
const mockEvent = {
|
||||
id: 42,
|
||||
slug: 'other-demo-2026-01-01',
|
||||
event_name: 'Demo',
|
||||
source_mode: 'managed',
|
||||
// Written through the backend by archiveService, so it is a bucket object
|
||||
// and the fs.unlink in the cascade never touched it on S3.
|
||||
archive_path: 'archives/other-demo-2026-01-01.zip',
|
||||
// The pre-built "Download All" zip. Lives under the event prefix, so the
|
||||
// recursive fs.rm covers it on local disk and nothing covers it on S3.
|
||||
download_zip_path: 'events/active/other-demo-2026-01-01/.download-cache/all.zip',
|
||||
};
|
||||
|
||||
const mockPhotos = [
|
||||
{
|
||||
id: 1,
|
||||
path: 'other-demo-2026-01-01/photo_one.jpg',
|
||||
thumbnail_path: 'thumbnails/thumb_aaa_photo_one.jpg',
|
||||
hero_path: null,
|
||||
preview_path: 'previews/prev_aaa_photo_one.jpg',
|
||||
watermark_path: 'watermarked/wm_aaa_photo_one.jpg',
|
||||
source_origin: 'managed',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
path: 'other-demo-2026-01-01/photo_two.jpg',
|
||||
thumbnail_path: 'thumbnails/thumb_bbb_photo_two.jpg',
|
||||
hero_path: null,
|
||||
preview_path: null,
|
||||
watermark_path: null,
|
||||
source_origin: 'managed',
|
||||
},
|
||||
{
|
||||
// External photos live outside the managed backend and must be left alone.
|
||||
id: 3,
|
||||
path: 'ignored.jpg',
|
||||
thumbnail_path: null,
|
||||
hero_path: null,
|
||||
preview_path: null,
|
||||
watermark_path: null,
|
||||
source_origin: 'external',
|
||||
},
|
||||
];
|
||||
|
||||
let mockPhotoRowsDeleted = false;
|
||||
let mockJobRowsDeleted = false;
|
||||
|
||||
// Photos in OTHER events that share a canonical derivative key with this one.
|
||||
let mockSharedDerivatives = [];
|
||||
|
||||
// The shared-derivative probe: db('photos').whereNot(...).where(cb).select(...)
|
||||
const sharedProbe = {
|
||||
where: () => sharedProbe,
|
||||
whereIn: () => sharedProbe,
|
||||
orWhereIn: () => sharedProbe,
|
||||
select: async () => mockSharedDerivatives,
|
||||
};
|
||||
|
||||
function mockMakeDb() {
|
||||
const table = (name) => {
|
||||
const chain = {
|
||||
where: () => chain,
|
||||
first: async () => (name === 'events' ? mockEvent : undefined),
|
||||
whereNotNull: () => chain,
|
||||
whereNot: () => sharedProbe,
|
||||
orWhereIn: () => chain,
|
||||
whereIn: () => chain,
|
||||
select: async () => {
|
||||
if (name === 'photos') {
|
||||
// The whole point: if this runs after the transaction, the rows
|
||||
// are gone and we would collect nothing.
|
||||
return mockPhotoRowsDeleted ? [] : mockPhotos;
|
||||
}
|
||||
return [];
|
||||
},
|
||||
del: async () => {
|
||||
if (name === 'photos') mockPhotoRowsDeleted = true;
|
||||
if (name === 'download_jobs') mockJobRowsDeleted = true;
|
||||
return 1;
|
||||
},
|
||||
};
|
||||
return chain;
|
||||
};
|
||||
// #1132 guards the merge-dismissals delete behind a hasTable check.
|
||||
table.schema = { hasTable: async () => false };
|
||||
table.transaction = async (cb) => cb(table);
|
||||
return table;
|
||||
}
|
||||
|
||||
jest.mock('../../src/database/db', () => ({
|
||||
db: mockMakeDb(),
|
||||
logActivity: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/services/storage', () => ({
|
||||
getStorage: () => mockStorage,
|
||||
}));
|
||||
|
||||
const { deleteEventCascade } = require('../../src/routes/adminEvents/helpers');
|
||||
|
||||
describe('deleteEventCascade — storage cleanup', () => {
|
||||
beforeEach(() => {
|
||||
mockStorage.delete.mockClear();
|
||||
mockPhotoRowsDeleted = false;
|
||||
mockJobRowsDeleted = false;
|
||||
mockSharedDerivatives = [];
|
||||
});
|
||||
|
||||
it('deletes originals and every derived tier from the storage backend', async () => {
|
||||
await deleteEventCascade(42, { id: 1, username: 'admin' });
|
||||
|
||||
const deleted = mockStorage.delete.mock.calls.map(([key]) => key);
|
||||
|
||||
expect(deleted).toEqual(expect.arrayContaining([
|
||||
'events/active/other-demo-2026-01-01/photo_one.jpg',
|
||||
'events/active/other-demo-2026-01-01/photo_two.jpg',
|
||||
'thumbnails/thumb_aaa_photo_one.jpg',
|
||||
'thumbnails/thumb_bbb_photo_two.jpg',
|
||||
'previews/prev_aaa_photo_one.jpg',
|
||||
]));
|
||||
});
|
||||
|
||||
it('deletes pre-generated watermarks and the archive zip', async () => {
|
||||
await deleteEventCascade(42, { id: 1, username: 'admin' });
|
||||
|
||||
const deleted = mockStorage.delete.mock.calls.map(([key]) => key);
|
||||
|
||||
// Both are storage-backend objects that only fs.unlink ever touched, so
|
||||
// both survived an event delete on S3.
|
||||
expect(deleted).toEqual(expect.arrayContaining([
|
||||
'watermarked/wm_aaa_photo_one.jpg',
|
||||
'archives/other-demo-2026-01-01.zip',
|
||||
]));
|
||||
});
|
||||
|
||||
it('deletes the Download All cache, which only fs.rm ever covered', async () => {
|
||||
await deleteEventCascade(42, { id: 1, username: 'admin' });
|
||||
|
||||
const deleted = mockStorage.delete.mock.calls.map(([key]) => key);
|
||||
|
||||
// Sits under events/active/{slug}/.download-cache/ — swept by the
|
||||
// recursive fs.rm on local disk, invisible to it on S3 where the prefix
|
||||
// is not a directory. Gallery-sized. (download_jobs is main-only, so the
|
||||
// per-job archives main also sweeps have no counterpart here.)
|
||||
expect(deleted).toContain(
|
||||
'events/active/other-demo-2026-01-01/.download-cache/all.zip'
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves a derivative alone when another event still points at it', async () => {
|
||||
// Canonical thumbnail/hero/preview keys are not event-scoped — the
|
||||
// basename is the photo's filename, and filenames are not unique across
|
||||
// events. Deleting one a surviving gallery still references would blank
|
||||
// its tile.
|
||||
mockSharedDerivatives = [{
|
||||
thumbnail_path: 'thumbnails/thumb_aaa_photo_one.jpg',
|
||||
hero_path: null,
|
||||
preview_path: null,
|
||||
watermark_path: null,
|
||||
}];
|
||||
|
||||
await deleteEventCascade(42, { id: 1, username: 'admin' });
|
||||
|
||||
const deleted = mockStorage.delete.mock.calls.map(([key]) => key);
|
||||
expect(deleted).not.toContain('thumbnails/thumb_aaa_photo_one.jpg');
|
||||
// The originals are slug-scoped and must still go.
|
||||
expect(deleted).toContain('events/active/other-demo-2026-01-01/photo_one.jpg');
|
||||
// So must a derivative nobody else claims.
|
||||
expect(deleted).toContain('thumbnails/thumb_bbb_photo_two.jpg');
|
||||
});
|
||||
|
||||
it('never asks the backend to delete the same key twice', async () => {
|
||||
await deleteEventCascade(42, { id: 1, username: 'admin' });
|
||||
|
||||
const managed = mockStorage.delete.mock.calls
|
||||
.map(([key]) => key)
|
||||
.filter((key) => !key.startsWith('thumbnails/thumb_w') && !key.startsWith('previews/preview_w'));
|
||||
|
||||
expect(managed).toEqual([...new Set(managed)]);
|
||||
});
|
||||
|
||||
it('leaves external/reference photos in place', async () => {
|
||||
await deleteEventCascade(42, { id: 1, username: 'admin' });
|
||||
|
||||
const deleted = mockStorage.delete.mock.calls.map(([key]) => key);
|
||||
expect(deleted).not.toEqual(expect.arrayContaining(['ignored.jpg']));
|
||||
expect(deleted).not.toEqual(expect.arrayContaining(['events/active/ignored.jpg']));
|
||||
});
|
||||
|
||||
it('still completes the delete when the storage backend throws', async () => {
|
||||
mockStorage.delete.mockRejectedValue(new Error('bucket unreachable'));
|
||||
|
||||
await expect(deleteEventCascade(42, { id: 1, username: 'admin' }))
|
||||
.resolves.toEqual({ id: 42, name: 'Demo' });
|
||||
|
||||
mockStorage.delete.mockResolvedValue(undefined);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* GHSA-9q5j-vqfw-32hr — the event-logo upload's multer `filename` callback
|
||||
* built the stored path directly from `req.params.id` with no integer
|
||||
* validation:
|
||||
*
|
||||
* filename: (req, file, cb) => {
|
||||
* cb(null, `event-${req.params.id}-logo-${Date.now()}${ext}`);
|
||||
* }
|
||||
*
|
||||
* A traversal payload in the `:id` route param (URL-encoded so it still
|
||||
* matches a single Express path segment, then decoded back into literal
|
||||
* `../` sequences by Express before handlers see it) could escape the
|
||||
* intended uploads/logos/events/ directory. Most directly reachable via a
|
||||
* super_admin session: requireEventOwnership short-circuits with next() and
|
||||
* zero DB lookup for that role (src/middleware/ownership.js), so nothing
|
||||
* upstream of multer validates the id first.
|
||||
*
|
||||
* Fixed by rejecting any non-positive-integer id before it is used to build
|
||||
* the filename, regardless of role or ownership-check ordering.
|
||||
*/
|
||||
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-events-logo-')), 'db.sqlite'
|
||||
);
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'admin-events-logo-test-secret';
|
||||
|
||||
const express = require('express');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const request = require('supertest');
|
||||
const { bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken } = require('../integration/helpers/crmDb');
|
||||
|
||||
async function insertEvent(db, adminId, over = {}) {
|
||||
const base = {
|
||||
slug: `ev-${Math.random().toString(16).slice(2)}`,
|
||||
event_type: 'wedding',
|
||||
event_name: 'Test Wedding',
|
||||
event_date: '2026-05-29',
|
||||
host_email: 'host@example.com',
|
||||
admin_email: 'admin@example.com',
|
||||
password_hash: 'x',
|
||||
share_link: `/gallery/share-${Math.random().toString(16).slice(2)}`,
|
||||
share_token: `st-${Math.random().toString(16).slice(2)}`,
|
||||
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
|
||||
is_active: 1, is_archived: 0, is_draft: 0,
|
||||
created_by: adminId,
|
||||
created_at: new Date().toISOString(),
|
||||
...over,
|
||||
};
|
||||
const r = await db('events').insert(base).returning('id');
|
||||
return r[0]?.id ?? r[0];
|
||||
}
|
||||
|
||||
describe('POST /api/admin/events/:id/logo — path traversal guard', () => {
|
||||
let db; let cleanup; let app; let adminId; let token;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
({ adminId } = await seedMinimal(db));
|
||||
// super_admin: requireEventOwnership short-circuits with no DB lookup
|
||||
// for this role, so it reaches multer with nothing upstream having
|
||||
// validated the id — the exact path GHSA-9q5j-vqfw-32hr exploited.
|
||||
await assignAdminRole(db, adminId, 'super_admin');
|
||||
token = mintAdminToken(adminId);
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use(cookieParser());
|
||||
app.use('/api/admin/events', require('../../src/routes/adminEvents'));
|
||||
// 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 () => { await cleanup(); });
|
||||
|
||||
const auth = (req) => req.set('Authorization', `Bearer ${token}`);
|
||||
const logoDir = () => path.join(process.env.STORAGE_PATH, 'uploads/logos/events');
|
||||
|
||||
it('rejects a traversal payload in the id param instead of writing outside uploads/logos/events', async () => {
|
||||
// '../../../../tmp/pwned' URL-encoded so the raw request path still has
|
||||
// a single segment (matches Express's `:id`), but Express decodes the
|
||||
// param back into literal '../' sequences before the route sees it.
|
||||
const traversalId = encodeURIComponent('../../../../tmp/pwned');
|
||||
|
||||
const res = await auth(
|
||||
request(app).post(`/api/admin/events/${traversalId}/logo`)
|
||||
).attach('logo', Buffer.from('fake image data'), 'logo.png');
|
||||
|
||||
expect(res.status).toBeGreaterThanOrEqual(400);
|
||||
expect(res.body.error).toMatch(/invalid event id/i);
|
||||
|
||||
// No file should have been written anywhere — the filename callback
|
||||
// must error out before multer opens a write stream.
|
||||
const escapedFile = path.join(os.tmpdir(), 'pwned');
|
||||
expect(fs.existsSync(escapedFile)).toBe(false);
|
||||
if (fs.existsSync(logoDir())) {
|
||||
expect(fs.readdirSync(logoDir())).toHaveLength(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('still accepts a normal numeric event id', async () => {
|
||||
const id = await insertEvent(db, adminId, { event_name: 'Logo Event' });
|
||||
|
||||
const res = await auth(
|
||||
request(app).post(`/api/admin/events/${id}/logo`)
|
||||
).attach('logo', Buffer.from('fake image data'), 'logo.png');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.hero_logo_url).toMatch(new RegExp(`^/uploads/logos/events/event-${id}-logo-`));
|
||||
|
||||
const files = fs.readdirSync(logoDir());
|
||||
expect(files.some((f) => f.startsWith(`event-${id}-logo-`))).toBe(true);
|
||||
|
||||
const row = await db('events').where({ id }).first();
|
||||
expect(row.hero_logo_url).toBe(res.body.hero_logo_url);
|
||||
});
|
||||
});
|
||||
@@ -38,6 +38,7 @@ const { authenticator } = require('otplib');
|
||||
const {
|
||||
bootCrmDb, mintAdminToken, buildRouteApp,
|
||||
} = require('../integration/helpers/crmDb');
|
||||
const mfaService = require('../../src/services/mfaService');
|
||||
|
||||
jest.setTimeout(120000);
|
||||
|
||||
@@ -235,6 +236,138 @@ describe('MFA disable — /api/admin/auth/mfa/disable', () => {
|
||||
expect(row.two_factor_secret).toBeNull();
|
||||
expect(row.two_factor_recovery_codes).toBeNull();
|
||||
});
|
||||
|
||||
// Concurrency regression: a plain UPDATE with no conditional guard let two
|
||||
// requests carrying the same captured code both read the same
|
||||
// two_factor_last_used_step and both persist, defeating replay protection.
|
||||
// The guarded UPDATE (mfaService.persistTotpStep) makes only the first
|
||||
// writer's affected-row count > 0; the loser must be rejected.
|
||||
it('two concurrent disable requests with the SAME captured code: only one succeeds', async () => {
|
||||
const admin = await seedAdmin();
|
||||
const { secret, token } = await enroll(admin.id);
|
||||
const code = authenticator.generate(secret);
|
||||
|
||||
const [r1, r2] = await Promise.all([
|
||||
request(adminApp)
|
||||
.post('/api/admin/auth/mfa/disable')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ code }),
|
||||
request(adminApp)
|
||||
.post('/api/admin/auth/mfa/disable')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ code }),
|
||||
]);
|
||||
|
||||
expect([r1.status, r2.status].sort()).toEqual([200, 400]);
|
||||
|
||||
const status = await request(adminApp)
|
||||
.get('/api/admin/auth/mfa/status')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(status.body.enabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MFA regenerate recovery codes — /api/admin/auth/mfa/recovery-codes', () => {
|
||||
it('a valid TOTP regenerates the recovery codes and persists the step', async () => {
|
||||
const admin = await seedAdmin();
|
||||
const { secret, token } = await enroll(admin.id);
|
||||
|
||||
const res = await request(adminApp)
|
||||
.post('/api/admin/auth/mfa/recovery-codes')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ code: authenticator.generate(secret) });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.recoveryCodes).toHaveLength(10);
|
||||
});
|
||||
|
||||
it('a wrong code is rejected (400)', async () => {
|
||||
const admin = await seedAdmin();
|
||||
const { secret, token } = await enroll(admin.id);
|
||||
const valid = authenticator.generate(secret);
|
||||
const wrong = valid === '000000' ? '111111' : '000000';
|
||||
|
||||
const res = await request(adminApp)
|
||||
.post('/api/admin/auth/mfa/recovery-codes')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ code: wrong });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
// Concurrency regression (see the disable test above for the mechanism):
|
||||
// this is the endpoint called out as the worst lost-update case, since it
|
||||
// both rotates the recovery codes and (previously) persisted the step in
|
||||
// one unconditional UPDATE.
|
||||
it('two concurrent regenerations with the SAME captured code: only one succeeds', async () => {
|
||||
const admin = await seedAdmin();
|
||||
const { secret, token } = await enroll(admin.id);
|
||||
const code = authenticator.generate(secret);
|
||||
|
||||
const [r1, r2] = await Promise.all([
|
||||
request(adminApp)
|
||||
.post('/api/admin/auth/mfa/recovery-codes')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ code }),
|
||||
request(adminApp)
|
||||
.post('/api/admin/auth/mfa/recovery-codes')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ code }),
|
||||
]);
|
||||
|
||||
expect([r1.status, r2.status].sort()).toEqual([200, 400]);
|
||||
const winner = r1.status === 200 ? r1 : r2;
|
||||
expect(winner.body.recoveryCodes).toHaveLength(10);
|
||||
|
||||
const row = await db('admin_users').where({ id: admin.id }).first();
|
||||
expect(row.two_factor_last_used_step).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('mfaService.persistTotpStep — atomic replay-tracking persist', () => {
|
||||
// Deterministic simulation of the race: two "concurrent" requests that
|
||||
// read the SAME two_factor_last_used_step and computed the SAME totpStep
|
||||
// from the same captured code. Calling persistTotpStep twice in a row with
|
||||
// that identical totpStep reproduces exactly the DB-level outcome of a
|
||||
// true race, without relying on event-loop timing.
|
||||
it('the second writer with the same totpStep affects 0 rows and is rejected', async () => {
|
||||
const admin = await seedAdmin();
|
||||
const { secret } = await enroll(admin.id);
|
||||
const row = await db('admin_users').where({ id: admin.id }).first();
|
||||
const code = authenticator.generate(secret);
|
||||
const totpStep = mfaService.verifyTotpEncryptedStep(code, row.two_factor_secret, null);
|
||||
expect(totpStep).toEqual(expect.any(Number));
|
||||
|
||||
const first = await mfaService.persistTotpStep(db, admin.id, totpStep, { updated_at: new Date() });
|
||||
expect(first).toBe(true);
|
||||
|
||||
// The row's two_factor_last_used_step has now already advanced to
|
||||
// totpStep by the time this "losing" write runs — the guard condition
|
||||
// (whereNull OR < totpStep) is false, so 0 rows are affected.
|
||||
const second = await mfaService.persistTotpStep(db, admin.id, totpStep, { updated_at: new Date() });
|
||||
expect(second).toBe(false);
|
||||
|
||||
const after = await db('admin_users').where({ id: admin.id }).first();
|
||||
expect(Number(after.two_factor_last_used_step)).toBe(totpStep);
|
||||
});
|
||||
|
||||
it('succeeds when the new step advances past the current one', async () => {
|
||||
const admin = await seedAdmin();
|
||||
const { secret } = await enroll(admin.id);
|
||||
const row = await db('admin_users').where({ id: admin.id }).first();
|
||||
const code = authenticator.generate(secret);
|
||||
const totpStep = mfaService.verifyTotpEncryptedStep(code, row.two_factor_secret, null);
|
||||
|
||||
const ok = await mfaService.persistTotpStep(db, admin.id, totpStep, {});
|
||||
expect(ok).toBe(true);
|
||||
|
||||
const nextStepAuthenticator = authenticator.clone({ epoch: Date.now() + 30000 });
|
||||
const nextCode = nextStepAuthenticator.generate(secret);
|
||||
const nextStep = mfaService.verifyTotpEncryptedStep(nextCode, row.two_factor_secret, totpStep);
|
||||
expect(nextStep).toBeGreaterThan(totpStep);
|
||||
|
||||
const advanced = await mfaService.persistTotpStep(db, admin.id, nextStep, {});
|
||||
expect(advanced).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Admin login challenge — /api/auth/admin/login[/mfa]', () => {
|
||||
@@ -284,6 +417,86 @@ describe('Admin login challenge — /api/auth/admin/login[/mfa]', () => {
|
||||
expect(res.body.user.id).toBe(admin.id);
|
||||
});
|
||||
|
||||
// GHSA-qcwx-r25m-j869: verifyTotp() was stateless, so otplib's window:1
|
||||
// tolerance let the same 6-digit code complete two independent logins
|
||||
// within its ~90s validity window. mfaService now tracks each admin's
|
||||
// last-consumed TOTP step and rejects a code that doesn't advance past it.
|
||||
it('#GHSA-qcwx-r25m-j869 — a TOTP code cannot be replayed into a second login', async () => {
|
||||
const admin = await seedAdmin();
|
||||
const { secret } = await enroll(admin.id);
|
||||
const code = authenticator.generate(secret);
|
||||
|
||||
// First use of the code completes a login.
|
||||
const c1 = await request(authApp)
|
||||
.post('/api/auth/admin/login')
|
||||
.send({ username: admin.username, password: admin.password });
|
||||
const first = await request(authApp)
|
||||
.post('/api/auth/admin/login/mfa')
|
||||
.send({ mfaToken: c1.body.mfaToken, code });
|
||||
expect(first.status).toBe(200);
|
||||
expect(first.body.user).toBeDefined();
|
||||
|
||||
// Replaying the SAME code for an independent second login must fail,
|
||||
// even though otplib's window:1 tolerance still considers it valid.
|
||||
const c2 = await request(authApp)
|
||||
.post('/api/auth/admin/login')
|
||||
.send({ username: admin.username, password: admin.password });
|
||||
const replay = await request(authApp)
|
||||
.post('/api/auth/admin/login/mfa')
|
||||
.send({ mfaToken: c2.body.mfaToken, code });
|
||||
expect(replay.status).toBe(401);
|
||||
expect(replay.body.code).toBe('MFA_INVALID');
|
||||
expect(replay.body.user).toBeUndefined();
|
||||
|
||||
// A freshly generated code for the NEXT TOTP step is not a replay and
|
||||
// succeeds. Generated via a cloned authenticator with a future epoch
|
||||
// rather than mocking Date.now(), so mfaService's own step computation
|
||||
// (real Date.now()) still lands the match one step ahead.
|
||||
const nextStepAuthenticator = authenticator.clone({ epoch: Date.now() + 30000 });
|
||||
const nextCode = nextStepAuthenticator.generate(secret);
|
||||
const c3 = await request(authApp)
|
||||
.post('/api/auth/admin/login')
|
||||
.send({ username: admin.username, password: admin.password });
|
||||
const third = await request(authApp)
|
||||
.post('/api/auth/admin/login/mfa')
|
||||
.send({ mfaToken: c3.body.mfaToken, code: nextCode });
|
||||
expect(third.status).toBe(200);
|
||||
expect(third.body.user).toBeDefined();
|
||||
expect(third.body.user.id).toBe(admin.id);
|
||||
});
|
||||
|
||||
// Concurrency regression: verifyTotpEncryptedStep()'s "does this advance"
|
||||
// check was read against a snapshot taken earlier in the request, then a
|
||||
// PLAIN update persisted the step — two concurrent requests carrying the
|
||||
// SAME captured code could both pass the check and both complete a login
|
||||
// before either write landed. The persist is now a conditional UPDATE
|
||||
// (mfaService.persistTotpStep), so only the first writer's affected-row
|
||||
// count is > 0 and the other is correctly treated as a replay.
|
||||
it('two concurrent login/mfa requests with the SAME captured code: only one completes', async () => {
|
||||
const admin = await seedAdmin();
|
||||
const { secret } = await enroll(admin.id);
|
||||
const code = authenticator.generate(secret);
|
||||
|
||||
const c1 = await request(authApp)
|
||||
.post('/api/auth/admin/login')
|
||||
.send({ username: admin.username, password: admin.password });
|
||||
const c2 = await request(authApp)
|
||||
.post('/api/auth/admin/login')
|
||||
.send({ username: admin.username, password: admin.password });
|
||||
|
||||
const [r1, r2] = await Promise.all([
|
||||
request(authApp).post('/api/auth/admin/login/mfa').send({ mfaToken: c1.body.mfaToken, code }),
|
||||
request(authApp).post('/api/auth/admin/login/mfa').send({ mfaToken: c2.body.mfaToken, code }),
|
||||
]);
|
||||
|
||||
expect([r1.status, r2.status].sort()).toEqual([200, 401]);
|
||||
const winner = r1.status === 200 ? r1 : r2;
|
||||
const loser = r1.status === 200 ? r2 : r1;
|
||||
expect(winner.body.user).toBeDefined();
|
||||
expect(loser.body.user).toBeUndefined();
|
||||
expect(loser.body.code).toBe('MFA_INVALID');
|
||||
});
|
||||
|
||||
it('login/mfa with a wrong code is 401 MFA_INVALID', async () => {
|
||||
const admin = await seedAdmin();
|
||||
const { secret } = await enroll(admin.id);
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* GHSA-9h7q-2jpf-vj85 — DELETE /api/admin/short-urls/:id only checked
|
||||
* `events.edit` permission, with no ownership scoping. GET and POST for an
|
||||
* event's short URLs both chain requireEventOwnership; DELETE takes the
|
||||
* short URL row's own :id (not :eventId), so any admin holding events.edit
|
||||
* could delete another admin's branded gallery short URL. The route now
|
||||
* resolves the short URL's event first and applies the same ownership
|
||||
* predicate requireEventOwnership uses. 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-suown-')), 'db.sqlite',
|
||||
);
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'suown-test-secret';
|
||||
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-suown-storage-'));
|
||||
|
||||
const request = require('supertest');
|
||||
const express = require('express');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const { bootCrmDb, assignAdminRole, mintAdminToken } = require('../integration/helpers/crmDb');
|
||||
|
||||
describe('short URL delete ownership scoping', () => {
|
||||
let db; let cleanup; let app; let service;
|
||||
let superTok; let ownerTok; let foreignTok;
|
||||
let ownerId;
|
||||
let foreignShortUrlId;
|
||||
|
||||
const auth = (req, tok) => req.set('Authorization', `Bearer ${tok}`);
|
||||
|
||||
async function seedEvent(createdBy, slugSuffix) {
|
||||
const farFuture = new Date(Date.now() + 365 * 86400000).toISOString();
|
||||
const [id] = await db('events').insert({
|
||||
slug: `suown-${slugSuffix}`,
|
||||
event_type: 'wedding',
|
||||
event_name: 'Test Event',
|
||||
event_date: '2026-08-01',
|
||||
host_email: 'h@e.com',
|
||||
admin_email: 'a@e.com',
|
||||
password_hash: 'x',
|
||||
share_link: `suown-${slugSuffix}`,
|
||||
share_token: `suown-share-${slugSuffix}`,
|
||||
expires_at: farFuture,
|
||||
is_active: true,
|
||||
is_archived: false,
|
||||
created_by: createdBy,
|
||||
created_at: new Date().toISOString(),
|
||||
});
|
||||
return db('events').where({ id }).first();
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
service = require('../../src/services/galleryShortUrlService');
|
||||
|
||||
const superIns = await db('admin_users').insert({
|
||||
username: 'suown-super', email: 'suown-super@example.com',
|
||||
password_hash: 'x', must_change_password: false, created_at: new Date(),
|
||||
}).returning('id');
|
||||
const superId = superIns[0]?.id ?? superIns[0];
|
||||
await assignAdminRole(db, superId, 'super_admin');
|
||||
superTok = mintAdminToken(superId);
|
||||
|
||||
const ownerIns = await db('admin_users').insert({
|
||||
username: 'suown-owner', email: 'suown-owner@example.com',
|
||||
password_hash: 'x', must_change_password: false, created_at: new Date(),
|
||||
}).returning('id');
|
||||
ownerId = ownerIns[0]?.id ?? ownerIns[0];
|
||||
await assignAdminRole(db, ownerId, 'editor');
|
||||
ownerTok = mintAdminToken(ownerId);
|
||||
|
||||
const foreignIns = await db('admin_users').insert({
|
||||
username: 'suown-foreign', email: 'suown-foreign@example.com',
|
||||
password_hash: 'x', must_change_password: false, created_at: new Date(),
|
||||
}).returning('id');
|
||||
const foreignId = foreignIns[0]?.id ?? foreignIns[0];
|
||||
await assignAdminRole(db, foreignId, 'editor');
|
||||
foreignTok = mintAdminToken(foreignId);
|
||||
|
||||
// Event owned by `owner`, NOT `foreign`.
|
||||
await seedEvent(ownerId, 'owned');
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use(cookieParser());
|
||||
app.use('/api/admin', require('../../src/routes/adminShortUrls'));
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
beforeEach(async () => {
|
||||
// Fresh short URL per DELETE test so earlier deletes don't interfere.
|
||||
const event = await db('events').where({ created_by: ownerId }).first();
|
||||
const row = await service.createShortUrl({
|
||||
eventId: event.id,
|
||||
customSlug: `suown-target-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
||||
createdBy: ownerId,
|
||||
});
|
||||
foreignShortUrlId = row.id;
|
||||
});
|
||||
|
||||
it('an admin who does not own the event cannot delete its short URL (403, row survives)', async () => {
|
||||
const res = await auth(
|
||||
request(app).delete(`/api/admin/short-urls/${foreignShortUrlId}`),
|
||||
foreignTok,
|
||||
);
|
||||
expect(res.status).toBe(403);
|
||||
const row = await db('gallery_short_urls').where({ id: foreignShortUrlId }).first();
|
||||
expect(row).toBeDefined();
|
||||
expect(row.deleted_at).toBeFalsy();
|
||||
});
|
||||
|
||||
it('the owning admin can delete its own short URL', async () => {
|
||||
const res = await auth(
|
||||
request(app).delete(`/api/admin/short-urls/${foreignShortUrlId}`),
|
||||
ownerTok,
|
||||
);
|
||||
expect(res.status).toBe(204);
|
||||
const row = await db('gallery_short_urls').where({ id: foreignShortUrlId }).first();
|
||||
expect(row.deleted_at).toBeTruthy();
|
||||
});
|
||||
|
||||
it('super_admin can delete any short URL', async () => {
|
||||
const res = await auth(
|
||||
request(app).delete(`/api/admin/short-urls/${foreignShortUrlId}`),
|
||||
superTok,
|
||||
);
|
||||
expect(res.status).toBe(204);
|
||||
const row = await db('gallery_short_urls').where({ id: foreignShortUrlId }).first();
|
||||
expect(row.deleted_at).toBeTruthy();
|
||||
});
|
||||
|
||||
it('deleting a nonexistent short URL id returns 404', async () => {
|
||||
const res = await auth(
|
||||
request(app).delete('/api/admin/short-urls/9999999'),
|
||||
superTok,
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('deleting a nonexistent short URL id as a non-owner also returns 404 (existence check runs first)', async () => {
|
||||
const res = await auth(
|
||||
request(app).delete('/api/admin/short-urls/9999999'),
|
||||
foreignTok,
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* PUT /api/admin/database-backup/config must reject a
|
||||
* database_backup_destination_path that resolves inside a publicly served
|
||||
* directory (GHSA-jw8m-43r2-jqrm class, #1365).
|
||||
*
|
||||
* Before #1365, database_backup_destination_path was silently ignored by
|
||||
* databaseBackupService.backup() (a destructuring bug always fell back to
|
||||
* the hardcoded /backup/database), so this setting being freely writable by
|
||||
* any backup.create holder — the built-in `admin` role has it without
|
||||
* settings.edit or backup.restore — was harmless. Making the setting
|
||||
* actually take effect reopens the exact exfiltration path GHSA-jw8m fixed
|
||||
* for the per-request override, through the persisted setting instead.
|
||||
*/
|
||||
|
||||
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-config-')), 'db.sqlite',
|
||||
);
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'dbbackup-config-test-secret';
|
||||
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-dbbackup-storage-'));
|
||||
|
||||
const request = require('supertest');
|
||||
const express = require('express');
|
||||
const bcrypt = require('bcrypt');
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
|
||||
|
||||
describe('database backup destination-path config guard (GHSA-jw8m class, #1365)', () => {
|
||||
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-config@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(); });
|
||||
|
||||
it('rejects a destination inside the public uploads/logos mount', async () => {
|
||||
const res = await request(app)
|
||||
.put('/api/admin/database-backup/config')
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.send({ database_backup_destination_path: path.join(process.env.STORAGE_PATH, 'uploads', 'logos') });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
|
||||
// The seeded default must survive untouched — the rejected value never lands.
|
||||
const row = await db('app_settings').where({ setting_key: 'database_backup_destination_path' }).first();
|
||||
expect(JSON.parse(row.setting_value)).toBe('/backup/database');
|
||||
});
|
||||
|
||||
it('rejects a destination inside the public fonts mount', async () => {
|
||||
const res = await request(app)
|
||||
.put('/api/admin/database-backup/config')
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.send({ database_backup_destination_path: path.join(process.env.STORAGE_PATH, 'fonts') });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('accepts a destination outside any public mount', async () => {
|
||||
const safePath = path.join(process.env.STORAGE_PATH, 'db-backups');
|
||||
const res = await request(app)
|
||||
.put('/api/admin/database-backup/config')
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.send({ database_backup_destination_path: safePath });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const row = await db('app_settings').where({ setting_key: 'database_backup_destination_path' }).first();
|
||||
expect(JSON.parse(row.setting_value)).toBe(safePath);
|
||||
});
|
||||
|
||||
// A retention of 0 or less pushes cleanupOldBackups' cutoff to today or
|
||||
// the future, deleting every completed backup on the next scheduled run
|
||||
// — a backup.create holder achieving what backup.delete gates on /cleanup.
|
||||
it.each([-1, 0])('rejects database_backup_retention_days=%s', async (bad) => {
|
||||
const res = await request(app)
|
||||
.put('/api/admin/database-backup/config')
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.send({ database_backup_retention_days: bad });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('accepts a positive database_backup_retention_days', async () => {
|
||||
const res = await request(app)
|
||||
.put('/api/admin/database-backup/config')
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.send({ database_backup_retention_days: 90 });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const row = await db('app_settings').where({ setting_key: 'database_backup_retention_days' }).first();
|
||||
expect(JSON.parse(row.setting_value)).toBe(90);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,278 @@
|
||||
/**
|
||||
* Single-photo gallery downloads must go through the storage backend (#1048).
|
||||
*
|
||||
* `GET /api/gallery/:slug/download/:photoId` resolved a LOCAL filesystem path
|
||||
* unconditionally and handed it to res.sendFile. On an S3/R2 deployment
|
||||
* managed photos never exist on local disk, so every per-photo download 404'd
|
||||
* with ENOENT — while download-all and secure-images worked fine, because they
|
||||
* already went through getStorage(). The gallery looks healthy until a guest
|
||||
* clicks the download button on a single photo.
|
||||
*
|
||||
* The local branch is pinned just as hard: sendFile emits Content-Length,
|
||||
* Accept-Ranges, ETag and Last-Modified and answers Range with a 206. Routing
|
||||
* local installs through a bare stream.pipe(res) to share one code path would
|
||||
* silently drop all of that, and a resumed download would append a second full
|
||||
* body onto the partial 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-dl-')), 'db.sqlite',
|
||||
);
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'download-test-secret';
|
||||
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-dl-storage-'));
|
||||
|
||||
const { Readable } = require('stream');
|
||||
|
||||
const SLUG = 'download-gallery';
|
||||
const FILENAME = 'original.jpg';
|
||||
// Deliberately not written to disk anywhere: if the route reads the
|
||||
// filesystem instead of the backend, it cannot produce these bytes.
|
||||
const mockObjectBody = Buffer.from('S3-ONLY-ORIGINAL-BYTES-not-on-local-disk');
|
||||
const mockBackendKind = { value: 's3' };
|
||||
|
||||
const mockStorage = {
|
||||
kind: () => mockBackendKind.value,
|
||||
stat: jest.fn(async () => ({ size: mockObjectBody.length, mtime: new Date('2026-08-20T10:00:00Z') })),
|
||||
get: jest.fn(async () => Readable.from([mockObjectBody])),
|
||||
getRange: jest.fn(async (key, start, end) => Readable.from([mockObjectBody.subarray(start, end + 1)])),
|
||||
delete: jest.fn(async () => undefined),
|
||||
exists: jest.fn(async () => true),
|
||||
};
|
||||
|
||||
jest.mock('../../src/services/storage', () => ({
|
||||
getStorage: () => mockStorage,
|
||||
initStorage: async () => mockStorage,
|
||||
}));
|
||||
|
||||
const request = require('supertest');
|
||||
const express = require('express');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
|
||||
|
||||
describe('single-photo download through the storage backend (#1048)', () => {
|
||||
let db; let cleanup; let app; let eventId; let photoId;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
await seedMinimal(db);
|
||||
|
||||
const ev = await db('events').insert({
|
||||
slug: SLUG,
|
||||
event_type: 'wedding',
|
||||
event_name: 'Downloads',
|
||||
event_date: '2026-08-01',
|
||||
host_email: 'h@example.com',
|
||||
admin_email: 'a@example.com',
|
||||
password_hash: 'x',
|
||||
share_link: `/gallery/${SLUG}/s`,
|
||||
share_token: 'download-share',
|
||||
expires_at: new Date(Date.now() + 7 * 864e5).toISOString(),
|
||||
is_active: 1,
|
||||
is_archived: 0,
|
||||
is_draft: 0,
|
||||
require_password: 0,
|
||||
allow_downloads: 1,
|
||||
created_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
eventId = ev[0]?.id ?? ev[0];
|
||||
|
||||
const row = await db('photos').insert({
|
||||
event_id: eventId,
|
||||
filename: FILENAME,
|
||||
path: `${SLUG}/${FILENAME}`,
|
||||
type: 'individual',
|
||||
source_origin: 'managed',
|
||||
mime_type: 'image/jpeg',
|
||||
uploaded_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
photoId = row[0]?.id ?? row[0];
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use(cookieParser());
|
||||
app.use('/api/gallery', require('../../src/routes/gallery'));
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
beforeEach(() => {
|
||||
mockBackendKind.value = 's3';
|
||||
mockStorage.get.mockClear();
|
||||
mockStorage.getRange.mockClear();
|
||||
});
|
||||
|
||||
it('streams the stored object instead of 404ing on a local path', async () => {
|
||||
const res = await request(app)
|
||||
.get(`/api/gallery/${SLUG}/download/${photoId}`)
|
||||
.buffer(true)
|
||||
.parse((response, cb) => {
|
||||
const chunks = [];
|
||||
response.on('data', (c) => chunks.push(c));
|
||||
response.on('end', () => cb(null, Buffer.concat(chunks)));
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
// The bytes only exist in the backend — proof it did not read the disk.
|
||||
expect(res.body.equals(mockObjectBody)).toBe(true);
|
||||
expect(mockStorage.get).toHaveBeenCalledWith(`events/active/${SLUG}/${FILENAME}`);
|
||||
// Never written locally, so a filesystem read could not have served this.
|
||||
expect(fs.existsSync(path.join(process.env.STORAGE_PATH, 'events/active', SLUG, FILENAME))).toBe(false);
|
||||
});
|
||||
|
||||
it('sends Content-Length so the browser can show download progress', async () => {
|
||||
const res = await request(app).get(`/api/gallery/${SLUG}/download/${photoId}`);
|
||||
|
||||
expect(res.headers['content-length']).toBe(String(mockObjectBody.length));
|
||||
expect(res.headers['accept-ranges']).toBe('bytes');
|
||||
expect(res.headers['content-disposition']).toContain(FILENAME);
|
||||
});
|
||||
|
||||
it('answers a Range request with 206 and only the requested bytes', async () => {
|
||||
const res = await request(app)
|
||||
.get(`/api/gallery/${SLUG}/download/${photoId}`)
|
||||
.set('Range', 'bytes=0-9')
|
||||
.buffer(true)
|
||||
.parse((response, cb) => {
|
||||
const chunks = [];
|
||||
response.on('data', (c) => chunks.push(c));
|
||||
response.on('end', () => cb(null, Buffer.concat(chunks)));
|
||||
});
|
||||
|
||||
expect(res.status).toBe(206);
|
||||
expect(res.headers['content-range']).toBe(`bytes 0-9/${mockObjectBody.length}`);
|
||||
expect(res.headers['content-length']).toBe('10');
|
||||
expect(res.body.equals(mockObjectBody.subarray(0, 10))).toBe(true);
|
||||
expect(mockStorage.getRange).toHaveBeenCalledWith(`events/active/${SLUG}/${FILENAME}`, 0, 9);
|
||||
});
|
||||
|
||||
it('ignores a malformed Range rather than emitting a nonsense 206', async () => {
|
||||
const res = await request(app)
|
||||
.get(`/api/gallery/${SLUG}/download/${photoId}`)
|
||||
.set('Range', 'bytes=abc-def');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['content-range']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('404s cleanly when the object is missing from the backend', async () => {
|
||||
mockStorage.stat.mockResolvedValueOnce(null);
|
||||
|
||||
const res = await request(app).get(`/api/gallery/${SLUG}/download/${photoId}`);
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
// The error must not inherit the image headers staged for a successful
|
||||
// download, or the browser saves a .jpg containing JSON.
|
||||
expect(res.headers['content-type']).toMatch(/json/);
|
||||
expect(res.headers['content-disposition']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps res.sendFile on a local backend rather than a bare pipe', async () => {
|
||||
mockBackendKind.value = 'local';
|
||||
const abs = path.join(process.env.STORAGE_PATH, 'events/active', SLUG, FILENAME);
|
||||
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
||||
fs.writeFileSync(abs, 'local-disk-bytes');
|
||||
|
||||
const res = await request(app).get(`/api/gallery/${SLUG}/download/${photoId}`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockStorage.get).not.toHaveBeenCalled();
|
||||
// sendFile's signature: conditional-request headers a raw pipe never sets.
|
||||
expect(res.headers.etag).toBeDefined();
|
||||
expect(res.headers['last-modified']).toBeDefined();
|
||||
|
||||
fs.rmSync(abs, { force: true });
|
||||
});
|
||||
|
||||
it('does not serve a partial body when the If-Range validator is stale', async () => {
|
||||
// The object was replaced since the client's last attempt. Answering 206
|
||||
// from the new bytes would let it splice two versions into one file.
|
||||
const res = await request(app)
|
||||
.get(`/api/gallery/${SLUG}/download/${photoId}`)
|
||||
.set('Range', 'bytes=0-9')
|
||||
.set('If-Range', new Date('2020-01-01T00:00:00Z').toUTCString());
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['content-range']).toBeUndefined();
|
||||
expect(res.headers['content-length']).toBe(String(mockObjectBody.length));
|
||||
});
|
||||
|
||||
it('still serves 206 when the If-Range validator matches', async () => {
|
||||
const res = await request(app)
|
||||
.get(`/api/gallery/${SLUG}/download/${photoId}`)
|
||||
.set('Range', 'bytes=0-9')
|
||||
.set('If-Range', new Date('2026-08-20T10:00:00Z').toUTCString());
|
||||
|
||||
expect(res.status).toBe(206);
|
||||
expect(res.headers['content-range']).toBe(`bytes 0-9/${mockObjectBody.length}`);
|
||||
});
|
||||
|
||||
it('errors cleanly when the object vanishes between stat and get', async () => {
|
||||
// HeadObject succeeding does not mean GetObject will — a concurrent
|
||||
// delete lands here. The staged image headers must not escape with it.
|
||||
const gone = new Error('NoSuchKey');
|
||||
gone.name = 'NoSuchKey';
|
||||
mockStorage.get.mockRejectedValueOnce(gone);
|
||||
|
||||
const res = await request(app).get(`/api/gallery/${SLUG}/download/${photoId}`);
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.headers['content-type']).toMatch(/json/);
|
||||
expect(res.headers['content-disposition']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not send 206 headers before the range fetch can fail', async () => {
|
||||
// writeHead(206) before the await would make this ERR_HTTP_HEADERS_SENT.
|
||||
mockStorage.getRange.mockRejectedValueOnce(new Error('connection reset'));
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/gallery/${SLUG}/download/${photoId}`)
|
||||
.set('Range', 'bytes=0-9');
|
||||
|
||||
expect(res.status).toBe(500);
|
||||
expect(res.headers['content-type']).toMatch(/json/);
|
||||
expect(res.headers['content-range']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('answers HEAD from stat instead of draining the object out of S3', async () => {
|
||||
const before = (await db('photos').where('id', photoId).first()).download_count || 0;
|
||||
const logsBefore = (await db('access_logs').where({ photo_id: photoId, action: 'download' })).length;
|
||||
|
||||
const res = await request(app).head(`/api/gallery/${SLUG}/download/${photoId}`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['content-length']).toBe(String(mockObjectBody.length));
|
||||
expect(res.headers['accept-ranges']).toBe('bytes');
|
||||
// The whole point: no egress for a metadata probe.
|
||||
expect(mockStorage.get).not.toHaveBeenCalled();
|
||||
expect(mockStorage.getRange).not.toHaveBeenCalled();
|
||||
|
||||
// And no side effects: a probe is not a download.
|
||||
const after = (await db('photos').where('id', photoId).first()).download_count || 0;
|
||||
expect(after).toBe(before);
|
||||
const logsAfter = (await db('access_logs').where({ photo_id: photoId, action: 'download' })).length;
|
||||
expect(logsAfter).toBe(logsBefore);
|
||||
});
|
||||
|
||||
it('returns a clean error when the range stream dies before its first chunk', async () => {
|
||||
// Resolves, then errors — writeHead would already have committed the 206,
|
||||
// leaving a connection reset as the only possible outcome.
|
||||
const { Readable: R } = require('stream');
|
||||
mockStorage.getRange.mockImplementationOnce(async () => {
|
||||
const dead = new R({ read() { this.destroy(new Error('socket hang up')); } });
|
||||
return dead;
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/gallery/${SLUG}/download/${photoId}`)
|
||||
.set('Range', 'bytes=0-9');
|
||||
|
||||
expect(res.status).toBe(500);
|
||||
expect(res.headers['content-type']).toMatch(/json/);
|
||||
expect(res.headers['content-range']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,268 @@
|
||||
/**
|
||||
* Previewing an unpublished gallery through its SHORT share URL (#1386).
|
||||
*
|
||||
* /info has honoured admin_preview since #868, but two sibling routes never
|
||||
* did, and both sit on the short-URL path:
|
||||
*
|
||||
* GET /resolve/:identifier — filtered drafts out via ACTIVE_EVENT_FILTER
|
||||
* GET /:slug/verify-token/:token — same, inline
|
||||
*
|
||||
* With "use short gallery URLs" OFF the admin's View Gallery link carries the
|
||||
* slug, GalleryPage never calls /resolve, and the preview worked. With it ON
|
||||
* the link is the token form, GalleryPage resolves it first, and the draft
|
||||
* 404'd as "Gallery Not Found" — which is exactly what was reported.
|
||||
*
|
||||
* The relaxation is admin-preview-only, so the other half of these tests is
|
||||
* the part that must NOT move: anonymous callers still get 404 for a draft,
|
||||
* and GHSA-rh8r's rule (never hand a share_token back on a bare slug lookup)
|
||||
* has to survive the new path too.
|
||||
*/
|
||||
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-draft-preview-')), 'db.sqlite',
|
||||
);
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'draft-preview-test-secret';
|
||||
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-draft-preview-storage-'));
|
||||
|
||||
const request = require('supertest');
|
||||
const express = require('express');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const { bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken } = require('../integration/helpers/crmDb');
|
||||
|
||||
// Share-token fixtures, deliberately low-entropy and obviously fake. They
|
||||
// have to satisfy SHARE_TOKEN_REGEX (32 hex chars), and random-looking hex of
|
||||
// that shape is exactly what secret scanners flag — GitGuardian raised two
|
||||
// "Generic High Entropy Secret" findings on the first version of this file.
|
||||
const DRAFT_SLUG = 'draft-preview-event';
|
||||
const DRAFT_TOKEN = 'deadbeefdeadbeefdeadbeefdeadbeef';
|
||||
const LIVE_SLUG = 'published-event';
|
||||
const LIVE_TOKEN = 'feedfacefeedfacefeedfacefeedface';
|
||||
|
||||
describe('draft preview through the short share URL (#1386)', () => {
|
||||
let db; let cleanup; let app; let adminId; let foreignId;
|
||||
|
||||
// Two transports. admin_preview=1 is an intent flag authenticated by the
|
||||
// admin cookie — what the frontend sends. ?preview=<jwt> is the legacy
|
||||
// hand-built-link form, kept working.
|
||||
const preview = (id = adminId) => `preview=${mintAdminToken(id)}`;
|
||||
const asAdmin = (req, id = adminId) => req.set('Cookie', `admin_token=${mintAdminToken(id)}`);
|
||||
|
||||
async function insertEvent({ slug, token, isDraft }) {
|
||||
await db('events').insert({
|
||||
slug,
|
||||
event_type: 'wedding',
|
||||
event_name: slug,
|
||||
event_date: '2026-09-01',
|
||||
host_email: 'h@example.com',
|
||||
admin_email: 'a@example.com',
|
||||
password_hash: 'x',
|
||||
share_link: `/gallery/${slug}/${token}`,
|
||||
share_token: token,
|
||||
require_password: 0,
|
||||
expires_at: new Date(Date.now() + 7 * 864e5).toISOString(),
|
||||
is_active: 1,
|
||||
is_archived: 0,
|
||||
is_draft: isDraft ? 1 : 0,
|
||||
created_by: adminId,
|
||||
created_at: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
({ adminId } = await seedMinimal(db));
|
||||
await assignAdminRole(db, adminId);
|
||||
const [row] = await db('admin_users').insert({
|
||||
username: 'foreign', email: 'foreign@example.test', password_hash: 'unused', is_active: 1,
|
||||
}).returning('id');
|
||||
foreignId = row?.id ?? row;
|
||||
await assignAdminRole(db, foreignId, 'viewer');
|
||||
|
||||
await insertEvent({ slug: DRAFT_SLUG, token: DRAFT_TOKEN, isDraft: true });
|
||||
await insertEvent({ slug: LIVE_SLUG, token: LIVE_TOKEN, isDraft: false });
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use(cookieParser());
|
||||
app.use('/api/gallery', require('../../src/routes/gallery'));
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
describe('the reported case — admin previewing a draft', () => {
|
||||
it('resolves the draft by share token (was 404 "Gallery Not Found")', async () => {
|
||||
const res = await request(app).get(`/api/gallery/resolve/${DRAFT_TOKEN}?${preview()}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.slug).toBe(DRAFT_SLUG);
|
||||
expect(res.body.matchType).toBe('token');
|
||||
});
|
||||
|
||||
it('resolves the draft by full share link', async () => {
|
||||
const identifier = encodeURIComponent(`/gallery/${DRAFT_SLUG}/${DRAFT_TOKEN}`);
|
||||
const res = await request(app).get(`/api/gallery/resolve/${identifier}?${preview()}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.slug).toBe(DRAFT_SLUG);
|
||||
});
|
||||
|
||||
it('clears verify-token for the draft, the next step of the same flow', async () => {
|
||||
const res = await request(app)
|
||||
.get(`/api/gallery/${DRAFT_SLUG}/verify-token/${DRAFT_TOKEN}?${preview()}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.valid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// The transport the SHIPPED frontend uses. The first cut of this fix only
|
||||
// tested ?preview=, which the browser never sends on an API call — so the
|
||||
// suite passed while the feature stayed broken end to end. Caught in review.
|
||||
describe('admin_preview=1 authenticated by the admin cookie', () => {
|
||||
it('resolves the draft', async () => {
|
||||
const res = await asAdmin(
|
||||
request(app).get(`/api/gallery/resolve/${DRAFT_TOKEN}?admin_preview=1`),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.slug).toBe(DRAFT_SLUG);
|
||||
});
|
||||
|
||||
it('clears verify-token', async () => {
|
||||
const res = await asAdmin(
|
||||
request(app).get(`/api/gallery/${DRAFT_SLUG}/verify-token/${DRAFT_TOKEN}?admin_preview=1`),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('serves /info for the draft', async () => {
|
||||
const res = await asAdmin(
|
||||
request(app).get(`/api/gallery/${DRAFT_SLUG}/info?admin_preview=1`),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('serves draft MEDIA, which is what the flag on the URL is for', async () => {
|
||||
// AuthenticatedImage/Video use native fetch and never see the axios
|
||||
// interceptor, so the flag has to travel on the media URL itself. Without
|
||||
// it the preview loaded metadata and showed no images at all.
|
||||
const res = await asAdmin(
|
||||
request(app).get(`/api/gallery/${DRAFT_SLUG}/photos?admin_preview=1`),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('404s with the flag but no admin cookie — the flag authorizes nothing', async () => {
|
||||
const res = await request(app).get(`/api/gallery/resolve/${DRAFT_TOKEN}?admin_preview=1`);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('404s with the flag and a cookie that is not an admin JWT', async () => {
|
||||
const res = await request(app)
|
||||
.get(`/api/gallery/resolve/${DRAFT_TOKEN}?admin_preview=1`)
|
||||
.set('Cookie', 'admin_token=not-a-jwt');
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('what must not move', () => {
|
||||
it('404s an anonymous resolve of the draft token', async () => {
|
||||
const res = await request(app).get(`/api/gallery/resolve/${DRAFT_TOKEN}`);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('404s when ?preview= carries a token that is not a valid admin JWT', async () => {
|
||||
const res = await request(app).get(`/api/gallery/resolve/${DRAFT_TOKEN}?preview=not-a-jwt`);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('404s when ?preview= is absent entirely', async () => {
|
||||
const res = await request(app).get(`/api/gallery/resolve/${DRAFT_TOKEN}?preview=`);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('404s a non-owning admin on verify-token too (#1411)', async () => {
|
||||
// This route selected its own columns and omitted created_by, so the
|
||||
// ownership check saw an ownerless event and waved the caller through
|
||||
// while /resolve and /info refused them.
|
||||
const res = await asAdmin(
|
||||
request(app).get(`/api/gallery/${DRAFT_SLUG}/verify-token/${DRAFT_TOKEN}?admin_preview=1`),
|
||||
foreignId,
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('404s an admin who does not own the event (#1411)', async () => {
|
||||
// Was 200: a valid signature was the whole check, so any admin previewed
|
||||
// any draft, including another photographer's. Now ownership applies —
|
||||
// the same rule requireEventOwnership enforces everywhere else.
|
||||
const res = await asAdmin(
|
||||
request(app).get(`/api/gallery/resolve/${DRAFT_TOKEN}?admin_preview=1`),
|
||||
foreignId,
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
|
||||
const info = await asAdmin(
|
||||
request(app).get(`/api/gallery/${DRAFT_SLUG}/info?admin_preview=1`),
|
||||
foreignId,
|
||||
);
|
||||
expect(info.status).toBe(404);
|
||||
});
|
||||
|
||||
it('404s an admin whose role grants no gallery permissions (#1411)', async () => {
|
||||
// The owner, but stripped of events.view/photos.view.
|
||||
const original = (await db('admin_users').where({ id: adminId }).first()).role_id;
|
||||
await db('admin_users').where({ id: adminId }).update({ role_id: null });
|
||||
try {
|
||||
const res = await asAdmin(
|
||||
request(app).get(`/api/gallery/resolve/${DRAFT_TOKEN}?admin_preview=1`),
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
} finally {
|
||||
await db('admin_users').where({ id: adminId }).update({ role_id: original });
|
||||
}
|
||||
});
|
||||
|
||||
it('404s an admin whose account has been deactivated (#1411)', async () => {
|
||||
await db('admin_users').where({ id: adminId }).update({ is_active: 0 });
|
||||
try {
|
||||
const res = await asAdmin(
|
||||
request(app).get(`/api/gallery/resolve/${DRAFT_TOKEN}?admin_preview=1`),
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
} finally {
|
||||
await db('admin_users').where({ id: adminId }).update({ is_active: 1 });
|
||||
}
|
||||
});
|
||||
|
||||
it('404s an anonymous verify-token for the draft', async () => {
|
||||
const res = await request(app)
|
||||
.get(`/api/gallery/${DRAFT_SLUG}/verify-token/${DRAFT_TOKEN}`);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('still withholds the share_token on a bare slug lookup (GHSA-rh8r)', async () => {
|
||||
// The draft path must not become a way around the token-withholding rule.
|
||||
const res = await request(app).get(`/api/gallery/resolve/${DRAFT_SLUG}?${preview()}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.matchType).toBe('slug');
|
||||
expect(res.body.token).toBeUndefined();
|
||||
expect(res.body.share_link).toBeUndefined();
|
||||
expect(res.body.share_url).toBeUndefined();
|
||||
expect(JSON.stringify(res.body)).not.toContain(DRAFT_TOKEN);
|
||||
});
|
||||
|
||||
it('leaves the published gallery resolving anonymously, as before', async () => {
|
||||
const res = await request(app).get(`/api/gallery/resolve/${LIVE_TOKEN}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.slug).toBe(LIVE_SLUG);
|
||||
expect(res.body.token).toBe(LIVE_TOKEN);
|
||||
});
|
||||
|
||||
it('still 404s an identifier that matches nothing', async () => {
|
||||
const res = await request(app).get(`/api/gallery/resolve/no-such-gallery?${preview()}`);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* Videos under enhanced/maximum image protection (#1370).
|
||||
*
|
||||
* Both halves of the video path used to be routed through /api/secure-images
|
||||
* once an event left `standard` protection, and neither half could carry a
|
||||
* video:
|
||||
*
|
||||
* 1. galleryQueryService emitted `/api/secure-images/{slug}/secure/{id}/{{token}}`
|
||||
* as the video's `url`. The lightbox drops that straight into a <video>
|
||||
* element, nothing substitutes `{{token}}` (the helper that could is
|
||||
* unreferenced), and the route answers 403 "Invalid or expired token".
|
||||
* 2. Even with a valid token it would still fail: the secure-images route
|
||||
* pipes every byte through secureImageService.processProtectedImage,
|
||||
* which calls sharp() and throws on an mp4 → 404.
|
||||
*
|
||||
* The guest saw a poster frozen at 0:00 with no error of any kind.
|
||||
*
|
||||
* Videos now keep the JWT route at every protection level. That is not a new
|
||||
* exposure — thumbnails of those same videos have always been served from it —
|
||||
* so these tests also pin the inverse: still images must keep bouncing to the
|
||||
* secure endpoint. Every assertion here fails on the unfixed code except the
|
||||
* two guarding images.
|
||||
*/
|
||||
|
||||
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-video-urls-')), 'db.sqlite',
|
||||
);
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'video-urls-test-secret';
|
||||
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-video-urls-storage-'));
|
||||
|
||||
const request = require('supertest');
|
||||
const express = require('express');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
|
||||
|
||||
const SLUG = 'protected-video-gallery';
|
||||
const VIDEO_BYTES = Buffer.from('not really an mp4, but the route only streams bytes');
|
||||
|
||||
describe('videos stay playable under enhanced/maximum protection (#1370)', () => {
|
||||
let db; let cleanup; let app; let eventId; let videoId; let imageId;
|
||||
|
||||
async function setProtection(level) {
|
||||
await db('events').where('id', eventId).update({ protection_level: level });
|
||||
}
|
||||
|
||||
async function photoPayload(id) {
|
||||
const res = await request(app).get(`/api/gallery/${SLUG}/photos`);
|
||||
expect(res.status).toBe(200);
|
||||
const photo = res.body.photos.find((p) => p.id === id);
|
||||
expect(photo).toBeDefined();
|
||||
return photo;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
await seedMinimal(db);
|
||||
|
||||
const ev = await db('events').insert({
|
||||
slug: SLUG,
|
||||
event_type: 'wedding',
|
||||
event_name: 'Protected Video',
|
||||
event_date: '2026-09-01',
|
||||
host_email: 'h@example.com',
|
||||
admin_email: 'a@example.com',
|
||||
password_hash: 'x',
|
||||
share_link: `/gallery/${SLUG}/s`,
|
||||
share_token: 'protected-video-share',
|
||||
expires_at: new Date(Date.now() + 7 * 864e5).toISOString(),
|
||||
is_active: 1,
|
||||
is_archived: 0,
|
||||
is_draft: 0,
|
||||
// Password-free so verifyGalleryAccess takes the public path, same as
|
||||
// the sibling gallery suites.
|
||||
require_password: 0,
|
||||
created_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
eventId = ev[0]?.id ?? ev[0];
|
||||
|
||||
const mediaDir = path.join(process.env.STORAGE_PATH, 'events/active', SLUG, 'individual');
|
||||
fs.mkdirSync(mediaDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(mediaDir, 'clip.mp4'), VIDEO_BYTES);
|
||||
fs.writeFileSync(path.join(mediaDir, 'still.jpg'), Buffer.from('jpeg-ish'));
|
||||
|
||||
const vid = await db('photos').insert({
|
||||
event_id: eventId,
|
||||
filename: 'clip.mp4',
|
||||
path: `${SLUG}/individual/clip.mp4`,
|
||||
type: 'individual',
|
||||
media_type: 'video',
|
||||
mime_type: 'video/mp4',
|
||||
duration: 43,
|
||||
uploaded_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
videoId = vid[0]?.id ?? vid[0];
|
||||
|
||||
const img = await db('photos').insert({
|
||||
event_id: eventId,
|
||||
filename: 'still.jpg',
|
||||
path: `${SLUG}/individual/still.jpg`,
|
||||
type: 'individual',
|
||||
uploaded_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
imageId = img[0]?.id ?? img[0];
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use(cookieParser());
|
||||
app.use('/api/gallery', require('../../src/routes/gallery'));
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
describe.each(['enhanced', 'maximum'])('protection_level = %s', (level) => {
|
||||
beforeAll(async () => { await setProtection(level); });
|
||||
|
||||
test('the video url is the JWT route, not a {{token}} template', async () => {
|
||||
const photo = await photoPayload(videoId);
|
||||
expect(photo.url).toBe(`/api/gallery/${SLUG}/photo/${videoId}`);
|
||||
expect(photo.url).not.toContain('{{token}}');
|
||||
expect(photo.requires_token).toBe(false);
|
||||
});
|
||||
|
||||
test('the video streams instead of bouncing to the secure endpoint', async () => {
|
||||
const res = await request(app).get(`/api/gallery/${SLUG}/photo/${videoId}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['content-type']).toBe('video/mp4');
|
||||
expect(res.headers['accept-ranges']).toBe('bytes');
|
||||
expect(Buffer.from(res.body)).toEqual(VIDEO_BYTES);
|
||||
});
|
||||
|
||||
test('range requests still work, so seeking is possible', async () => {
|
||||
const res = await request(app)
|
||||
.get(`/api/gallery/${SLUG}/photo/${videoId}`)
|
||||
.set('Range', 'bytes=0-9');
|
||||
expect(res.status).toBe(206);
|
||||
expect(res.headers['content-range']).toBe(`bytes 0-9/${VIDEO_BYTES.length}`);
|
||||
});
|
||||
|
||||
test('still images keep bouncing to the secure endpoint', async () => {
|
||||
const photo = await photoPayload(imageId);
|
||||
expect(photo.url).toBe(`/api/secure-images/${SLUG}/secure/${imageId}/{{token}}`);
|
||||
expect(photo.requires_token).toBe(true);
|
||||
|
||||
const res = await request(app).get(`/api/gallery/${SLUG}/photo/${imageId}`);
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.body.error).toBe('Secure access required');
|
||||
});
|
||||
});
|
||||
|
||||
describe('protection_level = standard', () => {
|
||||
beforeAll(async () => { await setProtection('standard'); });
|
||||
|
||||
test('both media types take the JWT route, as before', async () => {
|
||||
expect((await photoPayload(videoId)).url).toBe(`/api/gallery/${SLUG}/photo/${videoId}`);
|
||||
expect((await photoPayload(imageId)).url).toBe(`/api/gallery/${SLUG}/photo/${imageId}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* POST /api/auth/password-strength is unauthenticated and feeds its body into
|
||||
* zxcvbn, whose matching is superlinear and runs synchronously on the event
|
||||
* loop. Behind express.json({ limit: '50mb' }) that made a single request a
|
||||
* whole-process denial of service: measured on this codebase, 1,000 characters
|
||||
* blocked for ~5 seconds and 5,000 did not return in two minutes.
|
||||
*
|
||||
* The control is the length cap inside validatePassword(), so it holds for
|
||||
* every caller. These tests pin the cap itself rather than the route, and use
|
||||
* a wall-clock ceiling that only an unbounded zxcvbn call can breach.
|
||||
*/
|
||||
const { validatePassword, MAX_PASSWORD_LENGTH } = require('../../src/utils/passwordValidation');
|
||||
|
||||
describe('password validation length cap (zxcvbn DoS)', () => {
|
||||
it('rejects an over-length password without doing superlinear work', () => {
|
||||
const huge = 'aA1!'.repeat(MAX_PASSWORD_LENGTH); // 4x the cap
|
||||
const started = Date.now();
|
||||
const result = validatePassword(huge);
|
||||
const elapsed = Date.now() - started;
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.join(' ')).toMatch(/at most 128 characters/);
|
||||
// Unbounded, this input would not return for minutes.
|
||||
expect(elapsed).toBeLessThan(250);
|
||||
});
|
||||
|
||||
it('is bounded at the cap itself, the worst input it will still analyse', () => {
|
||||
const atCap = 'aA1!'.repeat(MAX_PASSWORD_LENGTH / 4);
|
||||
expect(atCap).toHaveLength(MAX_PASSWORD_LENGTH);
|
||||
|
||||
// 128 was chosen so the worst input the validator will still analyse costs
|
||||
// about as much as an ordinary request (~41ms measured); 512 cost 1.4s.
|
||||
const started = Date.now();
|
||||
validatePassword(atCap);
|
||||
expect(Date.now() - started).toBeLessThan(1000);
|
||||
});
|
||||
|
||||
it('still accepts an ordinary strong password', () => {
|
||||
const result = validatePassword('Tr0ub4dour&3-horse-battery');
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('does not spin when a caller asks for a length the cap forbids', async () => {
|
||||
// Codex review. generateSecurePassword retried by recursing on any invalid
|
||||
// candidate, so the new cap made every candidate invalid for length > 128
|
||||
// and turned the call into unbounded recursion. It now refuses up front,
|
||||
// and the retry loop is bounded.
|
||||
const { generateSecurePassword } = require('../../src/utils/passwordValidation');
|
||||
|
||||
expect(generateSecurePassword({ length: 16 })).toHaveLength(16);
|
||||
expect(generateSecurePassword({ length: MAX_PASSWORD_LENGTH }))
|
||||
.toHaveLength(MAX_PASSWORD_LENGTH);
|
||||
expect(() => generateSecurePassword({ length: MAX_PASSWORD_LENGTH + 1 }))
|
||||
.toThrow(/at most 128/);
|
||||
});
|
||||
|
||||
it('does not echo the rejected password back in the error body', async () => {
|
||||
// Codex review round 2. express-validator's errors.array() carries the
|
||||
// submitted `value`, so the 400 for an oversized password returned the
|
||||
// password itself -- reflecting a credential, and re-allocating up to the
|
||||
// 50mb body limit on an unauthenticated endpoint, which partly undid the
|
||||
// DoS fix this branch exists for.
|
||||
const src = require('fs').readFileSync(
|
||||
require('path').join(__dirname, '../../src/routes/auth.js'), 'utf8');
|
||||
|
||||
// No route may hand errors.array() straight to the response.
|
||||
expect(src).not.toMatch(/errors:\s*errors\.array\(\)/);
|
||||
// ...and the shared helper that replaces it must drop `value`.
|
||||
const helper = require('fs').readFileSync(
|
||||
require('path').join(__dirname, '../../src/utils/routeHelpers.js'), 'utf8');
|
||||
expect(helper).toMatch(/safeValidationErrors\s*=\s*\(errors\)\s*=>\s*errors\.array\(\)\.map\(\(\{ value, \.\.\.rest \}\)/);
|
||||
});
|
||||
|
||||
it('applies the cap through the context wrapper too', async () => {
|
||||
const { validatePasswordInContext } = require('../../src/utils/passwordValidation');
|
||||
const huge = 'aA1!'.repeat(MAX_PASSWORD_LENGTH);
|
||||
const result = await validatePasswordInContext(huge, 'admin', {});
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -25,14 +25,18 @@ process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
|
||||
fs.mkdirSync(process.env.STORAGE_PATH, { recursive: true });
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'crm-route-test-secret';
|
||||
|
||||
const express = require('express');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const request = require('supertest');
|
||||
const { bootCrmDb, seedMinimal, createPublicToken, buildRouteApp } = require('../integration/helpers/crmDb');
|
||||
const tokenGuards = require('../../src/utils/publicTokenGuards');
|
||||
const { errorHandler } = require('../../src/middleware/errorHandler');
|
||||
|
||||
describe('publicContracts routes', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
let app;
|
||||
let appWithErrorHandler;
|
||||
let customerId;
|
||||
let contractId;
|
||||
|
||||
@@ -51,6 +55,17 @@ describe('publicContracts routes', () => {
|
||||
contractId = inserted[0]?.id ?? inserted[0];
|
||||
|
||||
app = buildRouteApp('/api/public/contracts', require('../../src/routes/publicContracts'));
|
||||
|
||||
// A second app instance wired to the REAL production error handler
|
||||
// (buildRouteApp's is a simplified stand-in that only reads
|
||||
// err.statusCode/err.status, which a bare MulterError doesn't set).
|
||||
// Used below to verify the actual 4xx contract end-to-end, not just
|
||||
// that multer aborted the request.
|
||||
appWithErrorHandler = express();
|
||||
appWithErrorHandler.use(express.json());
|
||||
appWithErrorHandler.use(cookieParser());
|
||||
appWithErrorHandler.use('/api/public/contracts', require('../../src/routes/publicContracts'));
|
||||
appWithErrorHandler.use(errorHandler);
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -131,6 +146,40 @@ describe('publicContracts routes', () => {
|
||||
.attach('file', Buffer.from('%PDF-1.4 fake'), 'signed.pdf');
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
// CVE-2026-82333 regression (#1374 follow-up): multer 2.3.0 added an
|
||||
// opt-in `fieldArrayIndexLimit` that must be set to actually close the
|
||||
// field-parser DoS — the version bump alone does nothing. This route is
|
||||
// unauthenticated (token-in-URL only), so it's the sharpest place to
|
||||
// prove a crafted request with an oversized array-index field name
|
||||
// (`evil[999999999]`) is rejected rather than accepted or left to hang.
|
||||
it('rejects a multipart request with an oversized array-index field name', async () => {
|
||||
const token = await createPublicToken(db, 'contract_action_tokens', {
|
||||
contract_id: contractId,
|
||||
});
|
||||
const res = await request(app)
|
||||
.post(`/api/public/contracts/${token}/upload-signed-pdf`)
|
||||
.field('evil[999999999]', 'x')
|
||||
.attach('file', Buffer.from('%PDF-1.4 fake'), 'signed.pdf');
|
||||
// multer aborts the request before the handler runs; buildRouteApp's
|
||||
// generic error handler falls back to 500 for a bare MulterError
|
||||
// (see appWithErrorHandler test below for the real 4xx contract), so
|
||||
// here we only assert the upload was NOT accepted/processed.
|
||||
expect(res.status).toBeGreaterThanOrEqual(400);
|
||||
expect(res.body.error).not.toBe(undefined);
|
||||
});
|
||||
|
||||
it('maps the oversized array-index rejection to a 400 through the real error handler', async () => {
|
||||
const token = await createPublicToken(db, 'contract_action_tokens', {
|
||||
contract_id: contractId,
|
||||
});
|
||||
const res = await request(appWithErrorHandler)
|
||||
.post(`/api/public/contracts/${token}/upload-signed-pdf`)
|
||||
.field('evil[999999999]', 'x')
|
||||
.attach('file', Buffer.from('%PDF-1.4 fake'), 'signed.pdf');
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.code).toBe('VALIDATION_ERROR');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /:token/pdf', () => {
|
||||
|
||||
@@ -22,29 +22,38 @@ process.env.JWT_SECRET = process.env.JWT_SECRET || 'restorepath-test-secret';
|
||||
|
||||
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
|
||||
|
||||
// `bootCrmDb()` hands back the process-wide `db` singleton (module cache —
|
||||
// see its own comment), so it must only be called ONCE per test file: a
|
||||
// second call re-runs migrations against the same connection, and the first
|
||||
// call's `cleanup()` (db.destroy()) would tear down the connection both
|
||||
// describe blocks below share. Boot once at file scope; each describe below
|
||||
// only touches app_settings / env vars, never the connection lifecycle.
|
||||
let db; let cleanup; let checkRestorePathsAllowed;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
await seedMinimal(db);
|
||||
({ checkRestorePathsAllowed } = require('../../src/routes/adminRestore')._internal);
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
async function setBackupSetting(key, value) {
|
||||
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',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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(); });
|
||||
await setBackupSetting('backup_destination_path', '/backup');
|
||||
});
|
||||
|
||||
it('allows the wizard\'s source TYPE tokens', async () => {
|
||||
for (const source of ['local', 's3', 'upload']) {
|
||||
@@ -84,3 +93,89 @@ describe('restore path allowlist (GHSA-fw4c)', () => {
|
||||
expect(err).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* GHSA-xfvx-j447-732c: `checkRestorePathsAllowed` constrained the top-level
|
||||
* `source`/`manifestPath` request fields (GHSA-fw4c above), but never looked
|
||||
* INSIDE the manifest itself. `manifest.database.backup_file` — handed
|
||||
* straight to restoreService's candidate resolution and eventually
|
||||
* interpolated into `sqlite3 .restore '<path>'` — was unchecked, so an
|
||||
* absolute path there could point the restore at an arbitrary file even
|
||||
* though `source`/`manifestPath` both passed containment.
|
||||
*/
|
||||
describe('restore path allowlist — manifest database.backup_file containment (GHSA-xfvx)', () => {
|
||||
let tmpRoot;
|
||||
|
||||
beforeAll(async () => {
|
||||
await setBackupSetting('backup_destination_path', '/backup');
|
||||
|
||||
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-xfvx-manifest-'));
|
||||
// Additional allowed root via the documented escape hatch — keeps this
|
||||
// describe block's fixtures out of the shared '/backup' root above.
|
||||
process.env.RESTORE_ALLOWED_ROOTS = tmpRoot;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
delete process.env.RESTORE_ALLOWED_ROOTS;
|
||||
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const writeManifest = (name, databaseSection) => {
|
||||
const manifestPath = path.join(tmpRoot, name);
|
||||
fs.writeFileSync(manifestPath, JSON.stringify({
|
||||
manifest: { version: '1.0', id: 'test' },
|
||||
backup: { type: 'full' },
|
||||
system: { platform: 'linux' },
|
||||
application: { version: '1.0.0' },
|
||||
files: { count: 0, manifest: [] },
|
||||
database: databaseSection,
|
||||
verification: { total_checksum: null, checksum_algorithm: null },
|
||||
}));
|
||||
return manifestPath;
|
||||
};
|
||||
|
||||
it('rejects a manifest whose database.backup_file is an absolute path outside every configured root', async () => {
|
||||
const manifestPath = writeManifest('evil-1.json', { backup_file: '/etc/passwd' });
|
||||
const err = await checkRestorePathsAllowed({ source: 'local', manifestPath });
|
||||
expect(err).toMatch(/database\.backup_file must be inside a configured backup location/i);
|
||||
});
|
||||
|
||||
it('accepts a manifest whose database.backup_file is an absolute path inside a configured root', async () => {
|
||||
const dbFile = path.join(tmpRoot, 'database', 'picpeak-db-sqlite-1.sql.gz');
|
||||
fs.mkdirSync(path.dirname(dbFile), { recursive: true });
|
||||
fs.writeFileSync(dbFile, 'not a real sqlite dump, just a fixture');
|
||||
const manifestPath = writeManifest('legit-1.json', { backup_file: dbFile });
|
||||
const err = await checkRestorePathsAllowed({ source: 'local', manifestPath });
|
||||
expect(err).toBeNull();
|
||||
});
|
||||
|
||||
it('does not choke on a manifest whose database.backup_file is a legitimate relative path', async () => {
|
||||
// Relative candidates are resolved against restoreService's own
|
||||
// `backupPath` (which this route-level pre-check doesn't have — it only
|
||||
// sees `source`/`manifestPath`), so this layer intentionally defers
|
||||
// relative-path containment to restoreService.performDatabaseRestore
|
||||
// and must not false-positive here.
|
||||
const manifestPath = writeManifest('legit-2.json', { backup_file: 'database/picpeak-db-sqlite-1.sql.gz' });
|
||||
const err = await checkRestorePathsAllowed({ source: 'local', manifestPath });
|
||||
expect(err).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects everything when no backup location is configured at all (fail closed, not fail open)', async () => {
|
||||
// Simulate an install that never had backup_destination_path /
|
||||
// backup_manifest_path seeded/configured, and isn't using the
|
||||
// RESTORE_ALLOWED_ROOTS escape hatch either.
|
||||
const savedRoots = process.env.RESTORE_ALLOWED_ROOTS;
|
||||
delete process.env.RESTORE_ALLOWED_ROOTS;
|
||||
await db('app_settings').whereIn('setting_key', ['backup_destination_path', 'backup_manifest_path']).del();
|
||||
|
||||
try {
|
||||
const err = await checkRestorePathsAllowed({
|
||||
source: '/backup/run-1', manifestPath: '/backup/run-1/manifest.json',
|
||||
});
|
||||
expect(err).toMatch(/no backup location is configured/i);
|
||||
} finally {
|
||||
process.env.RESTORE_ALLOWED_ROOTS = savedRoots;
|
||||
await setBackupSetting('backup_destination_path', '/backup');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Background zip rebuilds are capped (#1399).
|
||||
*
|
||||
* invalidateAll() invalidates every event holding a cached zip, and each
|
||||
* invalidate() arms its own debounce timer in the same tick — so they all fire
|
||||
* together. Every build opens its own storage reads, so a settings change
|
||||
* across 25 events was enough to exhaust the S3 agent pool and stall uploads,
|
||||
* thumbnails and gallery reads until the burst drained.
|
||||
*
|
||||
* The cap is on the BACKGROUND path only: a guest waiting on a download must
|
||||
* not be queued behind a settings-change burst.
|
||||
*/
|
||||
jest.mock('../../src/database/db', () => ({ db: jest.fn() }));
|
||||
jest.mock('../../src/utils/logger', () => ({
|
||||
info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn(),
|
||||
}));
|
||||
|
||||
const { db } = require('../../src/database/db');
|
||||
const service = require('../../src/services/downloadZipService');
|
||||
|
||||
const flush = () => new Promise((r) => setImmediate(r));
|
||||
|
||||
describe('downloadZipService background regen concurrency (#1399)', () => {
|
||||
let peak;
|
||||
let inFlight;
|
||||
let release;
|
||||
|
||||
beforeEach(() => {
|
||||
// setImmediate must stay real: the flush() helper below rides on it, and
|
||||
// jest's modern fake timers mock it too.
|
||||
jest.useFakeTimers({ doNotFake: ['setImmediate'] });
|
||||
peak = 0;
|
||||
inFlight = 0;
|
||||
release = [];
|
||||
service.regenActive = 0;
|
||||
service.regenWaiters = [];
|
||||
service.debounceTimers.clear();
|
||||
service.activeBuilds.clear();
|
||||
|
||||
jest.spyOn(service, 'generateZip').mockImplementation(() => {
|
||||
inFlight += 1;
|
||||
peak = Math.max(peak, inFlight);
|
||||
return new Promise((resolve) => {
|
||||
release.push(() => { inFlight -= 1; resolve(); });
|
||||
});
|
||||
});
|
||||
jest.spyOn(service, '_cleanup').mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('never runs more than two rebuilds at once, however many fire together', async () => {
|
||||
const rows = Array.from({ length: 12 }, (_, i) => ({ id: i + 1 }));
|
||||
db.mockReturnValue({
|
||||
whereNotNull: () => ({ select: () => Promise.resolve(rows) }),
|
||||
});
|
||||
|
||||
await service.invalidateAll();
|
||||
// Every debounce timer was armed in the same tick — fire them all.
|
||||
jest.runAllTimers();
|
||||
await flush();
|
||||
|
||||
expect(peak).toBe(2);
|
||||
expect(service.generateZip).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('starts the next rebuild as each one finishes', async () => {
|
||||
const rows = Array.from({ length: 5 }, (_, i) => ({ id: i + 1 }));
|
||||
db.mockReturnValue({
|
||||
whereNotNull: () => ({ select: () => Promise.resolve(rows) }),
|
||||
});
|
||||
|
||||
await service.invalidateAll();
|
||||
jest.runAllTimers();
|
||||
await flush();
|
||||
expect(service.generateZip).toHaveBeenCalledTimes(2);
|
||||
|
||||
release.shift()();
|
||||
await flush();
|
||||
expect(service.generateZip).toHaveBeenCalledTimes(3);
|
||||
expect(peak).toBe(2);
|
||||
|
||||
while (release.length) { release.shift()(); await flush(); }
|
||||
expect(service.generateZip).toHaveBeenCalledTimes(5);
|
||||
expect(peak).toBe(2);
|
||||
});
|
||||
|
||||
it('does not queue a foreground download behind the burst', async () => {
|
||||
const rows = Array.from({ length: 6 }, (_, i) => ({ id: i + 1 }));
|
||||
db.mockReturnValue({
|
||||
whereNotNull: () => ({ select: () => Promise.resolve(rows) }),
|
||||
});
|
||||
|
||||
await service.invalidateAll();
|
||||
jest.runAllTimers();
|
||||
await flush();
|
||||
expect(service.generateZip).toHaveBeenCalledTimes(2);
|
||||
|
||||
// A guest asking for a zip right now calls generateZip directly. It must
|
||||
// not park behind the two rebuilds already holding the slots.
|
||||
service.generateZip(999);
|
||||
await flush();
|
||||
expect(service.generateZip).toHaveBeenCalledWith(999);
|
||||
expect(inFlight).toBe(3);
|
||||
});
|
||||
|
||||
it('leaves the queue empty once every rebuild has run', async () => {
|
||||
const rows = Array.from({ length: 5 }, (_, i) => ({ id: i + 1 }));
|
||||
db.mockReturnValue({
|
||||
whereNotNull: () => ({ select: () => Promise.resolve(rows) }),
|
||||
});
|
||||
|
||||
await service.invalidateAll();
|
||||
jest.runAllTimers();
|
||||
await flush();
|
||||
expect(service.regenWaiters.length).toBeGreaterThan(0);
|
||||
|
||||
while (release.length) { release.shift()(); await flush(); }
|
||||
// Nothing parked, nothing counted as running — no slot leaked on the way
|
||||
// through, which is what would quietly wedge the next burst.
|
||||
expect(service.regenWaiters).toHaveLength(0);
|
||||
expect(service.regenActive).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* A failed pre-zip build must not leave storage reads open.
|
||||
*
|
||||
* The builder opened one storage read per photo and handed the raw stream to
|
||||
* archiver. archiver drains its queue one entry at a time, so on an S3 backend
|
||||
* every photo beyond the one being written parked a socket with a full receive
|
||||
* buffer, and the error path (a source stream dying, or a photo upload
|
||||
* invalidating the build) walked away from all of them. archiver's abort()
|
||||
* does not touch the source streams, and the AWS SDK arms its socket timeout
|
||||
* on a 3s delay then clears it once the response headers arrive, so nothing
|
||||
* ever reclaimed those sockets. On a live server 43 of the 50 pooled sockets
|
||||
* ended up stuck for days and photo uploads stopped completing.
|
||||
*/
|
||||
|
||||
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-zipleak-')), 'db.sqlite',
|
||||
);
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'zipleak-test-secret';
|
||||
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-zipleak-storage-'));
|
||||
|
||||
const { Readable } = require('stream');
|
||||
|
||||
const PHOTO_COUNT = 6;
|
||||
const MAX_INFLIGHT_READS = 2;
|
||||
|
||||
// One storage read. It never ends on its own, which is what a large photo
|
||||
// looks like to the builder: the bytes only move while archiver pulls them.
|
||||
class StoredObject extends Readable {
|
||||
constructor(key, failAfterReads, chunks) {
|
||||
super();
|
||||
this.key = key;
|
||||
this.failAfterReads = failAfterReads;
|
||||
this.chunks = chunks;
|
||||
this.reads = 0;
|
||||
}
|
||||
|
||||
_read() {
|
||||
this.reads += 1;
|
||||
if (this.failAfterReads && this.reads > this.failAfterReads) {
|
||||
// What a dropped connection to S3 looks like in Node.
|
||||
this.destroy(new Error('aborted'));
|
||||
return;
|
||||
}
|
||||
this.push(this.reads > this.chunks ? null : Buffer.alloc(4096, 1));
|
||||
}
|
||||
}
|
||||
|
||||
const reads = { opened: [], live: 0, peak: 0 };
|
||||
const failingKey = { value: null };
|
||||
const onOpen = { fn: null };
|
||||
// A read only finishes when the build pulls the whole object. Photos big
|
||||
// enough to matter never finish inside one archiver turn, and a stream that
|
||||
// ends on its own would be auto-destroyed and hide the leak.
|
||||
const objectChunks = { value: Number.POSITIVE_INFINITY };
|
||||
|
||||
function openStoredObject(key) {
|
||||
const stream = new StoredObject(key, key === failingKey.value ? 1 : 0, objectChunks.value);
|
||||
reads.opened.push(stream);
|
||||
reads.live += 1;
|
||||
if (reads.live > reads.peak) reads.peak = reads.live;
|
||||
let settled = false;
|
||||
const settle = () => { if (!settled) { settled = true; reads.live -= 1; } };
|
||||
stream.once('end', settle);
|
||||
stream.once('close', settle);
|
||||
if (onOpen.fn) onOpen.fn(reads.opened.length);
|
||||
return stream;
|
||||
}
|
||||
|
||||
const mockStorage = {
|
||||
kind: () => 's3',
|
||||
get: jest.fn(async (key) => openStoredObject(key)),
|
||||
getToFile: jest.fn(async () => undefined),
|
||||
putFromFile: jest.fn(async () => undefined),
|
||||
stat: jest.fn(async () => ({ size: 1234, mtime: new Date() })),
|
||||
delete: jest.fn(async () => undefined),
|
||||
exists: jest.fn(async () => true),
|
||||
};
|
||||
|
||||
jest.mock('../../src/services/storage', () => ({
|
||||
getStorage: () => mockStorage,
|
||||
initStorage: async () => mockStorage,
|
||||
}));
|
||||
|
||||
// Nothing to watermark, so the builder takes the stream-from-storage branch,
|
||||
// which is the one that holds sockets. (This branch has no rendition step —
|
||||
// the resize/watermark split that main mocks out here does not exist yet.)
|
||||
jest.mock('../../src/services/watermarkService', () => ({
|
||||
getWatermarkSettings: jest.fn(async () => ({ enabled: false })),
|
||||
applyWatermark: jest.fn(),
|
||||
}));
|
||||
|
||||
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
|
||||
const downloadZipService = require('../../src/services/downloadZipService');
|
||||
|
||||
describe('pre-zip build releases its storage reads', () => {
|
||||
let db; let cleanup; let eventId;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
await seedMinimal(db);
|
||||
|
||||
const ev = await db('events').insert({
|
||||
slug: 'zipleak',
|
||||
event_type: 'wedding',
|
||||
event_name: 'Zip Leak',
|
||||
event_date: '2026-09-01',
|
||||
host_email: 'h@example.com',
|
||||
admin_email: 'a@example.com',
|
||||
password_hash: 'x',
|
||||
share_link: '/gallery/zipleak/s',
|
||||
share_token: 'zipleak-share',
|
||||
expires_at: new Date(Date.now() + 7 * 864e5).toISOString(),
|
||||
is_active: 1,
|
||||
is_archived: 0,
|
||||
is_draft: 0,
|
||||
require_password: 0,
|
||||
allow_downloads: 1,
|
||||
created_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
eventId = ev[0]?.id ?? ev[0];
|
||||
|
||||
for (let i = 0; i < PHOTO_COUNT; i += 1) {
|
||||
await db('photos').insert({
|
||||
event_id: eventId,
|
||||
filename: `photo-${i}.jpg`,
|
||||
path: `zipleak/photo-${i}.jpg`,
|
||||
type: 'individual',
|
||||
source_origin: 'managed',
|
||||
mime_type: 'image/jpeg',
|
||||
visibility: 'visible',
|
||||
uploaded_at: new Date(Date.now() - i * 1000).toISOString(),
|
||||
});
|
||||
}
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
beforeEach(() => {
|
||||
reads.opened = [];
|
||||
reads.live = 0;
|
||||
reads.peak = 0;
|
||||
failingKey.value = null;
|
||||
onOpen.fn = null;
|
||||
objectChunks.value = Number.POSITIVE_INFINITY;
|
||||
mockStorage.get.mockClear();
|
||||
downloadZipService.versions.clear();
|
||||
downloadZipService.activeBuilds.clear();
|
||||
});
|
||||
|
||||
it('destroys every open read when a source stream dies mid-build', async () => {
|
||||
// The oldest photo is written first, so failing it strands the rest.
|
||||
failingKey.value = 'events/active/zipleak/photo-0.jpg';
|
||||
|
||||
const result = await downloadZipService.generateZip(eventId);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(reads.opened.length).toBeGreaterThan(1);
|
||||
const stranded = reads.opened.filter((s) => !s.destroyed);
|
||||
expect(stranded.map((s) => s.key)).toEqual([]);
|
||||
});
|
||||
|
||||
it('destroys every open read when an upload invalidates the build', async () => {
|
||||
// What adminPhotos does on every upload, delete and bulk edit, landing
|
||||
// while the archive is half built.
|
||||
onOpen.fn = (count) => {
|
||||
if (count !== 2) return;
|
||||
downloadZipService.invalidate(eventId);
|
||||
// invalidate() also schedules a rebuild; this test is not about that.
|
||||
clearTimeout(downloadZipService.debounceTimers.get(eventId));
|
||||
downloadZipService.debounceTimers.delete(eventId);
|
||||
};
|
||||
|
||||
const result = await downloadZipService.generateZip(eventId);
|
||||
|
||||
expect(result).toEqual({ success: false, error: 'Build invalidated' });
|
||||
expect(reads.opened.filter((s) => !s.destroyed).map((s) => s.key)).toEqual([]);
|
||||
});
|
||||
|
||||
it('never holds more storage reads open than the build needs', async () => {
|
||||
objectChunks.value = 8;
|
||||
|
||||
const result = await downloadZipService.generateZip(eventId);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockStorage.get).toHaveBeenCalledTimes(PHOTO_COUNT);
|
||||
expect(reads.peak).toBeLessThanOrEqual(MAX_INFLIGHT_READS);
|
||||
});
|
||||
});
|
||||
@@ -102,6 +102,7 @@ jest.mock('../../src/utils/logger', () => ({
|
||||
}));
|
||||
|
||||
const invoiceService = require('../../src/services/invoiceService');
|
||||
const emailProcessor = require('../../src/services/emailProcessor');
|
||||
|
||||
function resetChains() {
|
||||
for (const k of Object.keys(tableChains)) delete tableChains[k];
|
||||
@@ -261,7 +262,10 @@ describe('invoiceService.releaseForDelivery', () => {
|
||||
});
|
||||
|
||||
describe('invoiceService.recordPaymentCheckAction', () => {
|
||||
beforeEach(() => resetChains());
|
||||
beforeEach(() => {
|
||||
resetChains();
|
||||
emailProcessor.queueEmail.mockClear();
|
||||
});
|
||||
|
||||
it('rejects invalid actions', async () => {
|
||||
await expect(invoiceService.recordPaymentCheckAction({
|
||||
@@ -321,6 +325,71 @@ describe('invoiceService.recordPaymentCheckAction', () => {
|
||||
token: 'a'.repeat(64), action: 'partial', amountMinor: 9999,
|
||||
})).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
|
||||
// GHSA-wg94-f86h-vq68 hardening: every write via this unauthenticated
|
||||
// route notifies the admin. Uses 'paid_full' as the exercised action —
|
||||
// it stays inside markPaid (no workflow-engine / PDF-rendering
|
||||
// dependencies to stub) while still going through the full
|
||||
// recordPaymentCheckAction write path.
|
||||
it('queues an admin notification email after a successful action', async () => {
|
||||
pickChainFor('invoice_payment_check_tokens')._firstValue = {
|
||||
id: 1, used_at: null,
|
||||
expires_at: new Date(Date.now() + 86400000),
|
||||
};
|
||||
pickChainFor('invoices')._firstValue = {
|
||||
id: 5, invoice_number: 'INV-0005', status: 'overdue',
|
||||
total_amount_minor: 10000, paid_amount_minor: 0, late_fee_amount_minor: 0,
|
||||
customer_account_id: 7, created_by_admin_id: 42,
|
||||
currency: 'CHF', language: 'de', event_id: null,
|
||||
};
|
||||
pickChainFor('admin_users')._firstValue = { id: 42, email: 'admin@example.com', username: 'admin' };
|
||||
pickChainFor('business_profile')._firstValue = null;
|
||||
pickChainFor('customer_accounts')._firstValue = { id: 7, email: 'c@example.com', display_name: 'Test Customer' };
|
||||
|
||||
const result = await invoiceService.recordPaymentCheckAction({
|
||||
token: 'a'.repeat(64), action: 'paid_full', ip: '203.0.113.7',
|
||||
});
|
||||
expect(result).toEqual({ applied: 'paid_full' });
|
||||
|
||||
expect(emailProcessor.queueEmail).toHaveBeenCalledTimes(1);
|
||||
const [, recipientEmail, templateKey, data] = emailProcessor.queueEmail.mock.calls[0];
|
||||
expect(recipientEmail).toBe('admin@example.com');
|
||||
expect(templateKey).toBe('invoice_payment_check_action_recorded');
|
||||
expect(data.invoice_number).toBe('INV-0005');
|
||||
expect(data.action).toBe('paid_full');
|
||||
expect(data.ip).toBe('203.0.113.7');
|
||||
});
|
||||
|
||||
it('does not fail (or roll back) the ledger write when the admin notification fails to send', async () => {
|
||||
pickChainFor('invoice_payment_check_tokens')._firstValue = {
|
||||
id: 1, used_at: null,
|
||||
expires_at: new Date(Date.now() + 86400000),
|
||||
};
|
||||
pickChainFor('invoices')._firstValue = {
|
||||
id: 5, invoice_number: 'INV-0005', status: 'overdue',
|
||||
total_amount_minor: 10000, paid_amount_minor: 0, late_fee_amount_minor: 0,
|
||||
customer_account_id: 7, created_by_admin_id: 42,
|
||||
currency: 'CHF', language: 'de', event_id: null,
|
||||
};
|
||||
pickChainFor('admin_users')._firstValue = { id: 42, email: 'admin@example.com', username: 'admin' };
|
||||
pickChainFor('business_profile')._firstValue = null;
|
||||
pickChainFor('customer_accounts')._firstValue = { id: 7, email: 'c@example.com', display_name: 'Test Customer' };
|
||||
emailProcessor.queueEmail.mockRejectedValueOnce(new Error('smtp down'));
|
||||
|
||||
// The write itself (token consumption + markPaid) must still
|
||||
// succeed — the notification is best-effort only.
|
||||
const result = await invoiceService.recordPaymentCheckAction({
|
||||
token: 'a'.repeat(64), action: 'paid_full', ip: '203.0.113.7',
|
||||
});
|
||||
expect(result).toEqual({ applied: 'paid_full' });
|
||||
|
||||
// Token was actually consumed (the real assertion that the write
|
||||
// committed): the mock chain's .update() ran with used_at set.
|
||||
const tokenChain = pickChainFor('invoice_payment_check_tokens');
|
||||
expect(tokenChain.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ used_at: expect.any(Date), used_action: 'paid_full' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('invoiceService.queuePaymentCheckEmail', () => {
|
||||
@@ -371,4 +440,35 @@ describe('invoiceService.queuePaymentCheckEmail', () => {
|
||||
expect(res.sent).toBe(true);
|
||||
expect(res.token).toMatch(/^[a-f0-9]{64}$/);
|
||||
});
|
||||
|
||||
// GHSA-wg94-f86h-vq68 hardening: token TTL shortened from 30 days to 72h.
|
||||
it('mints a token with a ~72h TTL, not the old 30-day window', async () => {
|
||||
pickChainFor('invoices')._firstValue = {
|
||||
id: 1, status: 'overdue',
|
||||
customer_account_id: 5,
|
||||
created_by_admin_id: 42,
|
||||
total_amount_minor: 10000,
|
||||
currency: 'CHF',
|
||||
language: 'de',
|
||||
reminder_level: 0,
|
||||
due_date: '2026-05-01',
|
||||
last_payment_check_at: null,
|
||||
event_id: null,
|
||||
};
|
||||
pickChainFor('admin_users')._firstValue = { id: 42, email: 'admin@example.com', username: 'admin' };
|
||||
pickChainFor('business_profile')._firstValue = null;
|
||||
pickChainFor('customer_accounts')._firstValue = { id: 5, email: 'c@example.com', display_name: 'Test' };
|
||||
|
||||
const before = Date.now();
|
||||
const res = await invoiceService.queuePaymentCheckEmail(1);
|
||||
expect(res.sent).toBe(true);
|
||||
|
||||
const tokenChain = pickChainFor('invoice_payment_check_tokens');
|
||||
const insertedRow = tokenChain.insert.mock.calls[0][0];
|
||||
const ttlMs = new Date(insertedRow.expires_at).getTime() - before;
|
||||
expect(ttlMs).toBeGreaterThan(71 * 60 * 60 * 1000);
|
||||
expect(ttlMs).toBeLessThanOrEqual(72 * 60 * 60 * 1000 + 5000);
|
||||
// Well under the old 30-day TTL — the actual regression guard.
|
||||
expect(ttlMs).toBeLessThan(24 * 60 * 60 * 1000 * 30);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -92,6 +92,52 @@ describe('mfaService — TOTP verification', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('mfaService — replay protection (GHSA-qcwx-r25m-j869)', () => {
|
||||
it('verifyTotp accepts a code once and rejects the same code as a replay', () => {
|
||||
const secret = mfaService.generateSecret();
|
||||
const code = authenticator.generate(secret);
|
||||
|
||||
// First use: no lastUsedStep yet, so it's accepted.
|
||||
expect(mfaService.verifyTotp(code, secret)).toBe(true);
|
||||
|
||||
// Simulate persisting the matched step and replaying the same code: the
|
||||
// matched step must strictly advance past lastUsedStep, so this fails.
|
||||
const step = mfaService.currentTotpStep();
|
||||
expect(mfaService.verifyTotp(code, secret, step)).toBe(false);
|
||||
// A lastUsedStep the code hasn't caught up to yet also rejects it.
|
||||
expect(mfaService.verifyTotp(code, secret, step + 1)).toBe(false);
|
||||
});
|
||||
|
||||
it('verifyTotpEncryptedStep returns the matched step on success and null on replay', () => {
|
||||
const secret = mfaService.generateSecret();
|
||||
const stored = mfaService.encryptSecret(secret);
|
||||
const code = authenticator.generate(secret);
|
||||
|
||||
const step = mfaService.verifyTotpEncryptedStep(code, stored, null);
|
||||
expect(step).toEqual(expect.any(Number));
|
||||
expect(step).toBeGreaterThan(0);
|
||||
|
||||
// Replaying the same code against the just-persisted step is rejected.
|
||||
expect(mfaService.verifyTotpEncryptedStep(code, stored, step)).toBeNull();
|
||||
});
|
||||
|
||||
it('a freshly generated code for the next TOTP step is accepted after a replay is rejected', () => {
|
||||
const secret = mfaService.generateSecret();
|
||||
const code = authenticator.generate(secret);
|
||||
const step = mfaService.verifyTotpEncryptedStep(code, mfaService.encryptSecret(secret), null)
|
||||
|| mfaService.currentTotpStep();
|
||||
|
||||
// Same-step replay: rejected.
|
||||
expect(mfaService.verifyTotp(code, secret, step)).toBe(false);
|
||||
|
||||
// A code minted for the next step (via a cloned authenticator with a
|
||||
// future epoch, not by mocking Date.now()) advances past last_used_step.
|
||||
const nextStepAuthenticator = authenticator.clone({ epoch: Date.now() + 30000 });
|
||||
const nextCode = nextStepAuthenticator.generate(secret);
|
||||
expect(mfaService.verifyTotp(nextCode, secret, step)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mfaService — otpauth URI / QR', () => {
|
||||
it('builds an otpauth:// URI containing issuer, account and secret', () => {
|
||||
const secret = mfaService.generateSecret();
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* GHSA-xfvx-j447-732c: the SQLite restore path let an attacker-influenced
|
||||
* `manifest.database.backup_file` replace the live database.
|
||||
*
|
||||
* Two independent bugs, both fixed here:
|
||||
*
|
||||
* 1. Candidate resolution (restoreService.js's performDatabaseRestore,
|
||||
* ~L1000) tried an absolute `dbBackupFile` and a
|
||||
* `path.join(backupPath, dbBackupFile)` candidate with NO check that
|
||||
* the resolved path actually stayed inside the configured backup
|
||||
* root — a manifest could point `.restore` at any file on disk.
|
||||
*
|
||||
* 2. The resolved path was interpolated unescaped into a
|
||||
* `sqlite3 .restore '<path>'` dot-command string. sqlite3's CLI
|
||||
* parses that string itself (not the shell), so a single quote in
|
||||
* the path breaks out of the quoted argument regardless of
|
||||
* spawn()'s `shell: false` argv separation.
|
||||
*
|
||||
* These tests pin the fix directly against the exported helpers
|
||||
* (`resolveContainedDbBackupCandidates`, `assertSafeSqlitePath`,
|
||||
* `isContainedInRoots`, `getConfiguredBackupRoots`) — the exact functions
|
||||
* `performDatabaseRestore` calls before ever running `sqlite3 .restore` —
|
||||
* rather than driving the full restore (which does a real `db.destroy()` +
|
||||
* live-file swap against the shared app db and isn't worth the added
|
||||
* fragility for what's fundamentally a path-validation contract).
|
||||
*/
|
||||
|
||||
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-restoresvc-')), 'db.sqlite',
|
||||
);
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'restoresvc-test-secret';
|
||||
|
||||
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
|
||||
|
||||
describe('restoreService — sqlite restore path safety (GHSA-xfvx)', () => {
|
||||
let db; let cleanup; let _internal;
|
||||
let backupPath;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
await seedMinimal(db);
|
||||
|
||||
// The restore run's resolved local backup root — analogous to
|
||||
// `localBackupPath` in restoreService.restore(). Real directory with a
|
||||
// real database/ subfolder, matching what a genuine backup run leaves
|
||||
// on disk.
|
||||
backupPath = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-xfvx-backuproot-'));
|
||||
fs.mkdirSync(path.join(backupPath, 'database'), { recursive: true });
|
||||
|
||||
({ _internal } = require('../../src/services/restoreService'));
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
describe('assertSafeSqlitePath — the sqlite3 dot-command injection gate', () => {
|
||||
it.each([
|
||||
['/backup/database/picpeak-db-sqlite-1.sql'],
|
||||
[`${backupPath || '/backup'}/database/picpeak-db-sqlite-2024-01-01.sql.gz`],
|
||||
])('accepts a normal backup path: %s', (p) => {
|
||||
expect(() => _internal.assertSafeSqlitePath(p)).not.toThrow();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['/backup/database/x\'; DROP TABLE admin_users; --.sql'],
|
||||
['/backup/database/x\' .restore \'/etc/passwd'],
|
||||
['/backup/database/x\n.shell rm -rf /'],
|
||||
['/backup/database/has space.sql'],
|
||||
['/backup/database/semi;colon.sql'],
|
||||
[null],
|
||||
[undefined],
|
||||
[42],
|
||||
])('rejects an unsafe/non-string path: %j', (p) => {
|
||||
expect(() => _internal.assertSafeSqlitePath(p)).toThrow(/unsafe path/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isContainedInRoots', () => {
|
||||
it('accepts a path inside a root', () => {
|
||||
expect(_internal.isContainedInRoots('/backup/database/x.sql', ['/backup'])).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts a root path equal to the root itself', () => {
|
||||
expect(_internal.isContainedInRoots('/backup', ['/backup'])).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a path outside every root', () => {
|
||||
expect(_internal.isContainedInRoots('/etc/passwd', ['/backup'])).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a sibling directory that merely shares a prefix', () => {
|
||||
// '/backup-evil' starts with the string '/backup' but is NOT inside it.
|
||||
expect(_internal.isContainedInRoots('/backup-evil/x.sql', ['/backup'])).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a `..`-traversal path that resolves outside the root', () => {
|
||||
expect(_internal.isContainedInRoots('/backup/../etc/passwd', ['/backup'])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getConfiguredBackupRoots', () => {
|
||||
afterEach(async () => {
|
||||
delete process.env.RESTORE_ALLOWED_ROOTS;
|
||||
await db('app_settings').whereIn('setting_key', ['backup_destination_path', 'backup_manifest_path']).del();
|
||||
});
|
||||
|
||||
it('always includes the trusted root even with nothing else configured', async () => {
|
||||
const roots = await _internal.getConfiguredBackupRoots('/some/trusted/backup-path');
|
||||
expect(roots).toContain(path.resolve('/some/trusted/backup-path'));
|
||||
});
|
||||
|
||||
it('adds configured backup_destination_path / backup_manifest_path and RESTORE_ALLOWED_ROOTS', async () => {
|
||||
await db('app_settings').insert([
|
||||
{ setting_key: 'backup_destination_path', setting_value: JSON.stringify('/backup/dest'), setting_type: 'backup' },
|
||||
{ setting_key: 'backup_manifest_path', setting_value: JSON.stringify('/backup/manifests'), setting_type: 'backup' },
|
||||
]);
|
||||
process.env.RESTORE_ALLOWED_ROOTS = '/extra/root';
|
||||
|
||||
const roots = await _internal.getConfiguredBackupRoots('/trusted');
|
||||
expect(roots).toEqual(expect.arrayContaining([
|
||||
path.resolve('/trusted'),
|
||||
path.resolve('/backup/dest'),
|
||||
path.resolve('/backup/manifests'),
|
||||
path.resolve('/extra/root'),
|
||||
]));
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveContainedDbBackupCandidates — the manifest.database.backup_file gate', () => {
|
||||
it('rejects an absolute backup_file outside every configured root, but still offers the safe legacy basename candidate', async () => {
|
||||
const candidates = await _internal.resolveContainedDbBackupCandidates(
|
||||
backupPath, '/etc/passwd', () => {}
|
||||
);
|
||||
// The raw absolute escape must NOT be present.
|
||||
expect(candidates).not.toContain('/etc/passwd');
|
||||
// Candidate (3), the basename-only legacy reconstruct, is inherently
|
||||
// safe (can't escape backupPath) and stays available as a fallback.
|
||||
expect(candidates).toContain(path.join(backupPath, 'database', 'passwd'));
|
||||
});
|
||||
|
||||
it('rejects a `..`-traversal relative backup_file, keeping only the contained legacy candidate', async () => {
|
||||
const candidates = await _internal.resolveContainedDbBackupCandidates(
|
||||
backupPath, '../../../../etc/passwd', () => {}
|
||||
);
|
||||
const escaped = candidates.some((c) => !_internal.isContainedInRoots(c, [path.resolve(backupPath)]));
|
||||
expect(escaped).toBe(false);
|
||||
expect(candidates).toContain(path.join(backupPath, 'database', 'passwd'));
|
||||
});
|
||||
|
||||
it('accepts a legitimate relative backup_file recorded by a real backup run', async () => {
|
||||
const candidates = await _internal.resolveContainedDbBackupCandidates(
|
||||
backupPath, 'database/picpeak-db-sqlite-2024-01-01.sql.gz', () => {}
|
||||
);
|
||||
expect(candidates).toContain(path.join(backupPath, 'database', 'picpeak-db-sqlite-2024-01-01.sql.gz'));
|
||||
// Every returned candidate must actually be safe to use.
|
||||
for (const c of candidates) {
|
||||
expect(_internal.isContainedInRoots(c, [path.resolve(backupPath)])).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts a legitimate absolute backup_file that IS inside backupPath (the real dumper shape)', async () => {
|
||||
const absFile = path.join(backupPath, 'database', 'picpeak-db-sqlite-2024-02-02.sql.gz');
|
||||
const candidates = await _internal.resolveContainedDbBackupCandidates(
|
||||
backupPath, absFile, () => {}
|
||||
);
|
||||
expect(candidates).toContain(absFile);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* DNS-rebinding follow-up to the blind-SSRF fix in restoreServiceS3Ssrf.test.js
|
||||
* (GHSA-vm2x-c628-3cx5).
|
||||
*
|
||||
* isHostAllowed()/validateExternalUrlWithAddresses() are check-then-connect
|
||||
* on their own: they resolve the S3 endpoint hostname once to vet it, then
|
||||
* hand a bare hostname to the AWS SDK, which resolves it AGAIN when it
|
||||
* actually connects. An attacker who controls DNS for the endpoint hostname
|
||||
* (or an infra DNS-rebinding condition) can answer the first lookup with a
|
||||
* public IP and the second with a private/metadata one.
|
||||
*
|
||||
* downloadFileFromS3() now builds pinned http/https agents (pinnedRequest.js
|
||||
* — the same primitive webhookDeliveryWorker.js on main uses for outbound
|
||||
* HTTP; ported here since stable didn't have it yet) from the validated
|
||||
* address and passes them into S3StorageAdapter, which threads them into
|
||||
* the S3Client's NodeHttpHandler requestHandler. This asserts that wiring:
|
||||
* the agents S3StorageAdapter receives resolve the endpoint hostname to
|
||||
* ONLY the address vetted during validation, and never fall through to a
|
||||
* second, real DNS lookup that a rebinding attacker could answer
|
||||
* differently.
|
||||
*/
|
||||
|
||||
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-restores3pin-')), 'db.sqlite',
|
||||
);
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'restores3pin-test-secret';
|
||||
|
||||
jest.mock('dns', () => {
|
||||
const actual = jest.requireActual('dns');
|
||||
return { ...actual, promises: { ...actual.promises, lookup: jest.fn() }, lookup: jest.fn() };
|
||||
});
|
||||
|
||||
let capturedConfig;
|
||||
jest.mock('../../src/services/storage/s3Storage', () =>
|
||||
jest.fn().mockImplementation((config) => {
|
||||
capturedConfig = config;
|
||||
return { download: jest.fn().mockResolvedValue(undefined) };
|
||||
})
|
||||
);
|
||||
|
||||
const dns = require('dns');
|
||||
const promiseLookup = dns.promises.lookup;
|
||||
const S3StorageAdapter = require('../../src/services/storage/s3Storage');
|
||||
const { RestoreService } = require('../../src/services/restoreService');
|
||||
|
||||
describe('downloadFileFromS3 DNS-rebinding pinning', () => {
|
||||
let restoreService;
|
||||
let originalNodeEnv;
|
||||
|
||||
beforeEach(() => {
|
||||
restoreService = new RestoreService();
|
||||
capturedConfig = undefined;
|
||||
promiseLookup.mockReset();
|
||||
dns.lookup.mockReset();
|
||||
S3StorageAdapter.mockClear();
|
||||
originalNodeEnv = process.env.NODE_ENV;
|
||||
process.env.NODE_ENV = 'production';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env.NODE_ENV = originalNodeEnv;
|
||||
capturedConfig?.httpAgent?.destroy();
|
||||
capturedConfig?.httpsAgent?.destroy();
|
||||
});
|
||||
|
||||
it('passes pinned http/https agents into S3StorageAdapter built from the validated address', async () => {
|
||||
promiseLookup.mockResolvedValue([{ address: '93.184.216.34', family: 4 }]);
|
||||
|
||||
await restoreService.downloadFileFromS3(
|
||||
's3://backups/manifest.json',
|
||||
'/tmp/whatever/manifest.json',
|
||||
{ endpoint: 'rebind.example.com', accessKeyId: 'k', secretAccessKey: 's' }
|
||||
);
|
||||
|
||||
expect(S3StorageAdapter).toHaveBeenCalledTimes(1);
|
||||
expect(capturedConfig.httpAgent).toBeInstanceOf(require('http').Agent);
|
||||
expect(capturedConfig.httpsAgent).toBeInstanceOf(require('https').Agent);
|
||||
});
|
||||
|
||||
it('the pinned agent never performs a second DNS lookup — rebinding to a private IP on the real resolver is ignored', async () => {
|
||||
// First (validation) lookup: public IP, passes the preflight.
|
||||
promiseLookup.mockResolvedValue([{ address: '93.184.216.34', family: 4 }]);
|
||||
// If the pinned agent ever fell through to a real lookup, this would
|
||||
// hand back a private/metadata address — simulating the rebind.
|
||||
dns.lookup.mockImplementation((_hostname, options, callback) => {
|
||||
if (typeof options === 'function') { callback = options; options = {}; }
|
||||
callback(null, ...(options?.all ? [[{ address: '169.254.169.254', family: 4 }]] : ['169.254.169.254', 4]));
|
||||
});
|
||||
|
||||
await restoreService.downloadFileFromS3(
|
||||
's3://backups/manifest.json',
|
||||
'/tmp/whatever/manifest.json',
|
||||
{ endpoint: 'rebind.example.com', accessKeyId: 'k', secretAccessKey: 's' }
|
||||
);
|
||||
|
||||
const pinnedLookup = capturedConfig.httpAgent.options.lookup;
|
||||
expect(typeof pinnedLookup).toBe('function');
|
||||
|
||||
const result = await new Promise((resolve, reject) => {
|
||||
pinnedLookup('rebind.example.com', {}, (err, address, family) => {
|
||||
if (err) return reject(err);
|
||||
resolve({ address, family });
|
||||
});
|
||||
});
|
||||
|
||||
// Only the address vetted during validation is ever handed back —
|
||||
// never the private address the real resolver would now answer with.
|
||||
expect(result).toEqual({ address: '93.184.216.34', family: 4 });
|
||||
expect(dns.lookup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a lookup for any hostname other than the one that was validated', async () => {
|
||||
promiseLookup.mockResolvedValue([{ address: '93.184.216.34', family: 4 }]);
|
||||
|
||||
await restoreService.downloadFileFromS3(
|
||||
's3://backups/manifest.json',
|
||||
'/tmp/whatever/manifest.json',
|
||||
{ endpoint: 'rebind.example.com', accessKeyId: 'k', secretAccessKey: 's' }
|
||||
);
|
||||
|
||||
const pinnedLookup = capturedConfig.httpAgent.options.lookup;
|
||||
|
||||
await expect(new Promise((resolve, reject) => {
|
||||
pinnedLookup('attacker-controlled.example', {}, (err, address) => {
|
||||
if (err) return reject(err);
|
||||
resolve(address);
|
||||
});
|
||||
})).rejects.toThrow(/hostname changed/i);
|
||||
});
|
||||
|
||||
it('does not pin agents when no custom endpoint is configured (default AWS, no rebinding surface)', async () => {
|
||||
await restoreService.downloadFileFromS3(
|
||||
's3://backups/manifest.json',
|
||||
'/tmp/whatever/manifest.json',
|
||||
{ accessKeyId: 'k', secretAccessKey: 's' }
|
||||
);
|
||||
|
||||
expect(promiseLookup).not.toHaveBeenCalled();
|
||||
expect(capturedConfig.httpAgent).toBeUndefined();
|
||||
expect(capturedConfig.httpsAgent).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Blind SSRF via the restore S3 download path (GHSA-vm2x-c628-3cx5).
|
||||
*
|
||||
* downloadFileFromS3() built a bare S3StorageAdapter and called .download()
|
||||
* directly, never running the DNS-resolving isHostAllowed() guard that
|
||||
* testConnection() applies elsewhere — so an admin with backup.restore could
|
||||
* point the request-supplied S3 endpoint at an internal/metadata address for
|
||||
* unauthenticated egress via the server. `s3Config` here is fully attacker
|
||||
* controlled (POST /api/admin/restore/validate and /restore/start take it
|
||||
* straight from the request body — see routes/adminRestore.js), unlike the
|
||||
* scheduled-backup S3 endpoint, which is vetted at settings-save time.
|
||||
*/
|
||||
|
||||
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-restores3ssrf-')), 'db.sqlite',
|
||||
);
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'restores3ssrf-test-secret';
|
||||
|
||||
jest.mock('dns', () => {
|
||||
const actual = jest.requireActual('dns');
|
||||
return { ...actual, promises: { ...actual.promises, lookup: jest.fn() } };
|
||||
});
|
||||
|
||||
jest.mock('../../src/services/storage/s3Storage', () =>
|
||||
jest.fn().mockImplementation(() => ({
|
||||
download: jest.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
);
|
||||
|
||||
const dns = require('dns');
|
||||
const lookup = dns.promises.lookup;
|
||||
const S3StorageAdapter = require('../../src/services/storage/s3Storage');
|
||||
const { RestoreService } = require('../../src/services/restoreService');
|
||||
|
||||
describe('downloadFileFromS3 SSRF guard (GHSA-vm2x-c628-3cx5)', () => {
|
||||
let restoreService;
|
||||
let originalNodeEnv;
|
||||
|
||||
beforeEach(() => {
|
||||
restoreService = new RestoreService();
|
||||
lookup.mockReset();
|
||||
S3StorageAdapter.mockClear();
|
||||
originalNodeEnv = process.env.NODE_ENV;
|
||||
process.env.NODE_ENV = 'production';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env.NODE_ENV = originalNodeEnv;
|
||||
});
|
||||
|
||||
it('rejects an endpoint hostname that resolves to a private/internal address before any network call', async () => {
|
||||
lookup.mockResolvedValue([{ address: '10.0.0.5', family: 4 }]);
|
||||
|
||||
await expect(
|
||||
restoreService.downloadFileFromS3(
|
||||
's3://backups/manifest.json',
|
||||
'/tmp/whatever/manifest.json',
|
||||
{ endpoint: 'evil-rebind.example.com', accessKeyId: 'k', secretAccessKey: 's' }
|
||||
)
|
||||
).rejects.toThrow(/private or internal network address/i);
|
||||
|
||||
expect(S3StorageAdapter).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects an endpoint hostname that resolves to the cloud metadata address', async () => {
|
||||
lookup.mockResolvedValue([{ address: '169.254.169.254', family: 4 }]);
|
||||
|
||||
await expect(
|
||||
restoreService.downloadFileFromS3(
|
||||
's3://backups/manifest.json',
|
||||
'/tmp/whatever/manifest.json',
|
||||
{ endpoint: 'metadata-rebind.example.com', accessKeyId: 'k', secretAccessKey: 's' }
|
||||
)
|
||||
).rejects.toThrow(/private or internal network address/i);
|
||||
|
||||
expect(S3StorageAdapter).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows a legitimate public S3 endpoint through to download()', async () => {
|
||||
lookup.mockResolvedValue([{ address: '93.184.216.34', family: 4 }]);
|
||||
|
||||
await restoreService.downloadFileFromS3(
|
||||
's3://backups/manifest.json',
|
||||
'/tmp/whatever/manifest.json',
|
||||
{ endpoint: 's3.example-cdn.com', accessKeyId: 'k', secretAccessKey: 's' }
|
||||
);
|
||||
|
||||
expect(S3StorageAdapter).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not require the guard outside production (dev MinIO stays usable), but still downloads', async () => {
|
||||
process.env.NODE_ENV = 'development';
|
||||
lookup.mockResolvedValue([{ address: '10.0.0.5', family: 4 }]); // would be rejected in prod
|
||||
|
||||
await restoreService.downloadFileFromS3(
|
||||
's3://backups/manifest.json',
|
||||
'/tmp/whatever/manifest.json',
|
||||
{ endpoint: 'localhost:9000', accessKeyId: 'k', secretAccessKey: 's' }
|
||||
);
|
||||
|
||||
expect(lookup).not.toHaveBeenCalled();
|
||||
expect(S3StorageAdapter).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* GHSA-h4w8-57xq-53fx entropy half: resetAdminPassword used to mint the
|
||||
* emailed temp password with generateReadablePassword() — 10 adjectives x
|
||||
* 10 nouns x crypto.randomInt(1000,9999) x 5 specials, ~2^21 possibilities,
|
||||
* brute-forceable. It now uses generateSecurePassword(16) (90-char charset),
|
||||
* same as every other security-sensitive password path in this file.
|
||||
*
|
||||
* Verified against a real SQLite DB (full core-migration set) so the
|
||||
* emailed plaintext, the stored hash, and must_change_password are all
|
||||
* checked end to end rather than against a mock.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
// bootCrmDb() sets TEST_DATABASE_PATH itself, but only in time for requires
|
||||
// that happen AFTER it runs (inside beforeAll). userManagementService.js
|
||||
// requires database/db.js at module load — i.e. before beforeAll — so that
|
||||
// connection has to be pointed at a fresh, unused test DB up front, or it
|
||||
// falls back to the shared default path and collides with whatever another
|
||||
// test file already migrated onto it. Same workaround as
|
||||
// userManagementService.activateDelete.test.js.
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-reset-pw-test-'));
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'db.sqlite');
|
||||
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
|
||||
fs.mkdirSync(process.env.STORAGE_PATH, { recursive: true });
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'reset-pw-test-secret';
|
||||
|
||||
const bcrypt = require('bcrypt');
|
||||
|
||||
const { bootCrmDb, seedMinimal, assignAdminRole } = require('../integration/helpers/crmDb');
|
||||
const userManagementService = require('../../src/services/userManagementService');
|
||||
|
||||
// The wordlist generateReadablePassword() used to produce:
|
||||
// <Adjective><Noun><4 digits><1 special>, e.g. "SwiftEagle4821!"
|
||||
const READABLE_WORDLIST_PATTERN = /^(Swift|Bright|Strong|Happy|Clever|Brave|Noble|Quick|Sharp|Bold)(Eagle|Mountain|River|Thunder|Forest|Ocean|Falcon|Dragon|Phoenix|Tiger)\d{4}[!@#$%]$/;
|
||||
|
||||
describe('userManagementService.resetAdminPassword (GHSA-h4w8-57xq-53fx)', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
let actorId;
|
||||
let targetId;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
({ adminId: actorId } = await seedMinimal(db));
|
||||
await assignAdminRole(db, actorId, 'super_admin');
|
||||
|
||||
const editor = await db('roles').where({ name: 'editor' }).first();
|
||||
const targetInsert = await db('admin_users').insert({
|
||||
username: 'reset-target', email: 'reset-target@example.com',
|
||||
password_hash: await bcrypt.hash('old-password', 4),
|
||||
role_id: editor?.id || null,
|
||||
is_active: 1, must_change_password: false, created_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
targetId = targetInsert[0]?.id ?? targetInsert[0];
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
it('generates a high-entropy password, not one drawn from the adjective/noun wordlist', async () => {
|
||||
const before = await db('admin_users').where({ id: targetId }).first();
|
||||
|
||||
await userManagementService.resetAdminPassword(targetId, actorId);
|
||||
|
||||
const emailRow = await db('email_queue')
|
||||
.where({ recipient_email: 'reset-target@example.com', email_type: 'admin_password_reset' })
|
||||
.orderBy('id', 'desc')
|
||||
.first();
|
||||
expect(emailRow).toBeDefined();
|
||||
const emailData = JSON.parse(emailRow.email_data);
|
||||
const newPassword = emailData.new_password;
|
||||
|
||||
// generateSecurePassword(16): fixed 16-char length, not the wordlist's
|
||||
// variable-length "WordWord####!" shape.
|
||||
expect(newPassword).toHaveLength(16);
|
||||
expect(newPassword).not.toMatch(READABLE_WORDLIST_PATTERN);
|
||||
// generateSecurePassword guarantees at least one of each character class.
|
||||
expect(newPassword).toMatch(/[a-z]/);
|
||||
expect(newPassword).toMatch(/[A-Z]/);
|
||||
expect(newPassword).toMatch(/[0-9]/);
|
||||
expect(newPassword).toMatch(/[!@#$%^&*()_+\-=[\]{}|;:,.<>?]/);
|
||||
|
||||
// The emailed plaintext actually matches what got persisted.
|
||||
const after = await db('admin_users').where({ id: targetId }).first();
|
||||
expect(after.password_hash).not.toBe(before.password_hash);
|
||||
await expect(bcrypt.compare(newPassword, after.password_hash)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('sets must_change_password so the enforcement backstop kicks in on next login', async () => {
|
||||
await db('admin_users').where({ id: targetId }).update({ must_change_password: false });
|
||||
|
||||
await userManagementService.resetAdminPassword(targetId, actorId);
|
||||
|
||||
const after = await db('admin_users').where({ id: targetId }).first();
|
||||
expect(after.must_change_password === true || after.must_change_password === 1).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* Privilege-escalation guard for PUT /api/admin/users/:id and
|
||||
* POST /api/admin/users/invite (GHSA-rv8w-m6mx-7j4q).
|
||||
*
|
||||
* updateAdminUser's role-change path previously enforced only:
|
||||
* (a) non-super_admin actors can't grant the super_admin role
|
||||
* (b) no self-role-update / demoting the last super_admin
|
||||
* It never checked whether the ACTOR's own permission set covers the
|
||||
* permissions carried by the role being granted — so an admin holding
|
||||
* only `users.edit` could hand any other admin a role (including the
|
||||
* built-in `admin` role) carrying far more permissions than the actor
|
||||
* itself held.
|
||||
*
|
||||
* createInvitation() had the identical gap: it only ever blocked
|
||||
* granting super_admin, so an admin holding only `users.create` could
|
||||
* invite a brand-new admin into any other role — including one carrying
|
||||
* far more permissions than the inviter itself held — via
|
||||
* POST /admin/users/invite.
|
||||
*
|
||||
* The fix adds assertActorMayGrant() — a local containment guard, since
|
||||
* stable does not yet have main's custom-role-creation service or its
|
||||
* roles.manage equivalent — inside both updateAdminUser's role_id branch
|
||||
* and createInvitation().
|
||||
*
|
||||
* Both describe blocks below share a single bootCrmDb() call: the
|
||||
* `db` module (`src/database/db.js`) is a singleton keyed off
|
||||
* TEST_DATABASE_PATH at first require, and bootCrmDb's own comment
|
||||
* warns that a second call after the first's cleanup() destroys the
|
||||
* pool, leaving "Unable to acquire a connection" for every later query.
|
||||
*/
|
||||
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-rolegrantguard-')), 'db.sqlite',
|
||||
);
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'rolegrantguard-test-secret';
|
||||
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-rolegrantguard-storage-'));
|
||||
|
||||
const { bootCrmDb, seedMinimal, assignAdminRole } = require('../integration/helpers/crmDb');
|
||||
const svc = require('../../src/services/userManagementService');
|
||||
const { clearPermissionCache } = require('../../src/middleware/permissions');
|
||||
|
||||
// Stable has no custom-role-creation service (that's main-only); build a role
|
||||
// directly against the roles/permissions/role_permissions schema instead.
|
||||
async function createRole(db, name, permissionNames) {
|
||||
const [roleRow] = await db('roles').insert({
|
||||
name, display_name: name, is_system: false, priority: 10, created_at: new Date(), updated_at: new Date(),
|
||||
}).returning('id');
|
||||
const roleId = roleRow?.id ?? roleRow;
|
||||
if (permissionNames.length > 0) {
|
||||
const perms = await db('permissions').whereIn('name', permissionNames).select('id', 'name');
|
||||
if (perms.length !== permissionNames.length) {
|
||||
throw new Error(`Missing seeded permission(s) for: ${permissionNames.join(', ')}`);
|
||||
}
|
||||
await db('role_permissions').insert(perms.map((p) => ({ role_id: roleId, permission_id: p.id })));
|
||||
}
|
||||
return { id: roleId };
|
||||
}
|
||||
|
||||
let db; let cleanup;
|
||||
let superId;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
({ adminId: superId } = await seedMinimal(db));
|
||||
await assignAdminRole(db, superId, 'super_admin');
|
||||
clearPermissionCache();
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
describe('updateAdminUser — role-grant privilege-escalation guard (GHSA-rv8w-m6mx-7j4q)', () => {
|
||||
let limitedRoleId; let limitedId; // holds only users.edit + events.view
|
||||
let powerfulRoleId; // carries settings.edit, which limitedId does NOT hold
|
||||
let modestRoleId; // carries only events.view, a subset of what limitedId holds
|
||||
let targetId; // account whose role limitedId will try to change
|
||||
|
||||
beforeAll(async () => {
|
||||
// The attacker in GHSA-rv8w-m6mx-7j4q: users.edit only, nothing else.
|
||||
const limitedRole = await createRole(db, 'limited_user_editor', ['users.edit', 'events.view']);
|
||||
limitedRoleId = limitedRole.id;
|
||||
const limitedIns = await db('admin_users').insert({
|
||||
username: 'limited', email: 'limited@example.com', password_hash: 'x',
|
||||
role_id: limitedRoleId, must_change_password: false, created_at: new Date(),
|
||||
}).returning('id');
|
||||
limitedId = limitedIns[0]?.id ?? limitedIns[0];
|
||||
|
||||
// A role carrying a permission the limited actor does not hold.
|
||||
const powerfulRole = await createRole(db, 'powerful_role', ['users.edit', 'settings.edit']);
|
||||
powerfulRoleId = powerfulRole.id;
|
||||
|
||||
// A role whose permissions ARE a subset of what the limited actor holds.
|
||||
const modestRole = await createRole(db, 'modest_role', ['events.view']);
|
||||
modestRoleId = modestRole.id;
|
||||
|
||||
clearPermissionCache();
|
||||
}, 120000);
|
||||
|
||||
beforeEach(async () => {
|
||||
// Fresh target for every test, role reset to modestRole so role-change
|
||||
// assertions always start from a known baseline.
|
||||
const existing = await db('admin_users').where({ username: 'target' }).first();
|
||||
if (existing) {
|
||||
targetId = existing.id;
|
||||
await db('admin_users').where({ id: targetId }).update({ role_id: modestRoleId });
|
||||
} else {
|
||||
const ins = await db('admin_users').insert({
|
||||
username: 'target', email: 'target@example.com', password_hash: 'x',
|
||||
role_id: modestRoleId, must_change_password: false, created_at: new Date(),
|
||||
}).returning('id');
|
||||
targetId = ins[0]?.id ?? ins[0];
|
||||
}
|
||||
});
|
||||
|
||||
it('refuses to let an admin grant a role carrying permissions the admin lacks', async () => {
|
||||
await expect(
|
||||
svc.updateAdminUser(
|
||||
targetId,
|
||||
{ role_id: powerfulRoleId },
|
||||
limitedId,
|
||||
{ roleName: 'limited_user_editor' },
|
||||
),
|
||||
).rejects.toThrow(/only grant permissions your own role/i);
|
||||
|
||||
// Target's role must be unchanged.
|
||||
const row = await db('admin_users').where({ id: targetId }).first();
|
||||
expect(row.role_id).toBe(modestRoleId);
|
||||
});
|
||||
|
||||
it('refuses to let an admin grant the built-in admin role beyond its own permissions', async () => {
|
||||
const adminRole = await db('roles').where({ name: 'admin' }).first();
|
||||
await expect(
|
||||
svc.updateAdminUser(
|
||||
targetId,
|
||||
{ role_id: adminRole.id },
|
||||
limitedId,
|
||||
{ roleName: 'limited_user_editor' },
|
||||
),
|
||||
).rejects.toThrow(/only grant permissions your own role/i);
|
||||
});
|
||||
|
||||
it('allows an admin to grant a role whose permissions it already holds', async () => {
|
||||
const updated = await svc.updateAdminUser(
|
||||
targetId,
|
||||
{ role_id: limitedRoleId },
|
||||
limitedId,
|
||||
{ roleName: 'limited_user_editor' },
|
||||
);
|
||||
expect(updated.role_id).toBe(limitedRoleId);
|
||||
});
|
||||
|
||||
it('super_admin can still grant any role, including one carrying more permissions than a limited actor holds', async () => {
|
||||
const updated = await svc.updateAdminUser(
|
||||
targetId,
|
||||
{ role_id: powerfulRoleId },
|
||||
superId,
|
||||
{ roleName: 'super_admin' },
|
||||
);
|
||||
expect(updated.role_id).toBe(powerfulRoleId);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createInvitation — role-grant privilege-escalation guard (GHSA-rv8w-m6mx-7j4q)', () => {
|
||||
let limitedRoleId; let limitedId; // holds only users.create + events.view
|
||||
let powerfulRoleId; // carries settings.edit, which limitedId does NOT hold
|
||||
let modestRoleId; // carries only events.view, a subset of what limitedId holds
|
||||
let inviteCounter = 0;
|
||||
|
||||
beforeAll(async () => {
|
||||
const limitedRole = await createRole(db, 'limited_inviter', ['users.create', 'events.view']);
|
||||
limitedRoleId = limitedRole.id;
|
||||
const limitedIns = await db('admin_users').insert({
|
||||
username: 'limited_inviter', email: 'limited_inviter@example.com', password_hash: 'x',
|
||||
role_id: limitedRoleId, must_change_password: false, created_at: new Date(),
|
||||
}).returning('id');
|
||||
limitedId = limitedIns[0]?.id ?? limitedIns[0];
|
||||
|
||||
const powerfulRole = await createRole(db, 'powerful_invite_role', ['users.create', 'settings.edit']);
|
||||
powerfulRoleId = powerfulRole.id;
|
||||
|
||||
const modestRole = await createRole(db, 'modest_invite_role', ['events.view']);
|
||||
modestRoleId = modestRole.id;
|
||||
|
||||
clearPermissionCache();
|
||||
}, 120000);
|
||||
|
||||
function nextEmail() {
|
||||
inviteCounter += 1;
|
||||
return `invitee-${inviteCounter}@example.com`;
|
||||
}
|
||||
|
||||
it('refuses to let an admin invite someone into a role carrying permissions the admin lacks', async () => {
|
||||
await expect(
|
||||
svc.createInvitation({
|
||||
email: nextEmail(),
|
||||
roleId: powerfulRoleId,
|
||||
invitedById: limitedId,
|
||||
inviterRoleName: 'limited_inviter',
|
||||
}),
|
||||
).rejects.toThrow(/only grant permissions your own role/i);
|
||||
});
|
||||
|
||||
it('allows an admin to invite someone into a role whose permissions it already holds', async () => {
|
||||
const invitation = await svc.createInvitation({
|
||||
email: nextEmail(),
|
||||
roleId: limitedRoleId,
|
||||
invitedById: limitedId,
|
||||
inviterRoleName: 'limited_inviter',
|
||||
});
|
||||
expect(invitation.role).toBeTruthy();
|
||||
});
|
||||
|
||||
it('allows an admin to invite someone into a role that is a subset of its own permissions', async () => {
|
||||
const invitation = await svc.createInvitation({
|
||||
email: nextEmail(),
|
||||
roleId: modestRoleId,
|
||||
invitedById: limitedId,
|
||||
inviterRoleName: 'limited_inviter',
|
||||
});
|
||||
expect(invitation.role).toBeTruthy();
|
||||
});
|
||||
|
||||
it('super_admin can still invite into any role, including one carrying more permissions than a limited actor holds', async () => {
|
||||
const invitation = await svc.createInvitation({
|
||||
email: nextEmail(),
|
||||
roleId: powerfulRoleId,
|
||||
invitedById: superId,
|
||||
inviterRoleName: 'super_admin',
|
||||
});
|
||||
expect(invitation.role).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* generateVideoPlaceholder() must not touch the database when the caller
|
||||
* already supplies width/height (videoProcessor.js's thumbnail-generation
|
||||
* fallback does exactly this).
|
||||
*
|
||||
* Why it matters: processUploadedPhotos() (chunked video upload) holds a
|
||||
* per-file SQLite transaction open across thumbnail generation. SQLite's
|
||||
* knex pool defaults to a single connection, so any second, un-transacted
|
||||
* db() query made while that transaction is open blocks until
|
||||
* acquireConnectionTimeout (60s in production) — verified directly against
|
||||
* an isolated SQLite db (codex review of #1371/#1372). Passing explicit
|
||||
* dimensions must skip getThumbnailSettings()'s db() call entirely, not
|
||||
* just tolerate its failure.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const os = require('os');
|
||||
|
||||
const mockDbSpy = jest.fn(() => {
|
||||
throw new Error('db() must not be called when width/height are supplied');
|
||||
});
|
||||
jest.mock('../../src/database/db', () => ({ db: (...args) => mockDbSpy(...args) }));
|
||||
|
||||
const storageModule = require('../../src/services/storage');
|
||||
const LocalFsStorage = require('../../src/services/storage/LocalFsStorage');
|
||||
|
||||
describe('generateVideoPlaceholder skips the settings DB lookup given explicit dimensions', () => {
|
||||
let storage;
|
||||
let root;
|
||||
let imageProcessor;
|
||||
|
||||
beforeAll(async () => {
|
||||
root = await fs.mkdtemp(path.join(os.tmpdir(), 'picpeak-vidplaceholder-'));
|
||||
storage = new LocalFsStorage({ root });
|
||||
await storage.init();
|
||||
storageModule.setStorageForTesting(storage);
|
||||
imageProcessor = require('../../src/services/imageProcessor');
|
||||
}, 30000);
|
||||
|
||||
afterAll(async () => {
|
||||
storageModule.resetStorage();
|
||||
await fs.rm(root, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => mockDbSpy.mockClear());
|
||||
|
||||
it('never calls db() when width/height are provided', async () => {
|
||||
const key = await imageProcessor.generateVideoPlaceholder('demo.mp4', { width: 300, height: 300 });
|
||||
|
||||
expect(key).toBe('thumbnails/thumb_demo.jpg');
|
||||
expect(await storage.exists(key)).toBe(true);
|
||||
expect(mockDbSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls through to defaults (not a throw) when db() fails and no dimensions were given', async () => {
|
||||
const key = await imageProcessor.generateVideoPlaceholder('demo2.mp4');
|
||||
|
||||
expect(key).toBe('thumbnails/thumb_demo2.jpg');
|
||||
expect(mockDbSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Bounded, reclaimable storage reads for archiver downloads (#1399 follow-up).
|
||||
*
|
||||
* archiver drains the sources it is handed one at a time, so appending a
|
||||
* storage read per photo opens N and drains one. Every other read parks its
|
||||
* socket holding unread bytes, and nothing reclaims them: archiver's abort()
|
||||
* does not touch source streams, and the S3 SDK clears its socket timeout as
|
||||
* soon as response headers land. That is the mechanism behind the incident in
|
||||
* PR #1402 — 43 of 50 pooled sockets held, uploads starved, restart required.
|
||||
*
|
||||
* #1402 fixes the cached-zip builder. These are the guarantees the same guard
|
||||
* has to give the three remaining call sites, two of which need no admin
|
||||
* credentials to reach.
|
||||
*/
|
||||
const { Readable } = require('stream');
|
||||
const { createArchiveStreamGuard } = require('../../src/utils/archiveStreamGuard');
|
||||
|
||||
const makeStream = () => new Readable({ read() {} });
|
||||
|
||||
describe('archiveStreamGuard (#1399 follow-up)', () => {
|
||||
it('lets the configured number of reads run at once', async () => {
|
||||
const guard = createArchiveStreamGuard({ maxInFlight: 2 });
|
||||
expect(await guard.acquire()).toBe(true);
|
||||
guard.track(makeStream());
|
||||
expect(await guard.acquire()).toBe(true);
|
||||
guard.track(makeStream());
|
||||
expect(guard.openCount).toBe(2);
|
||||
});
|
||||
|
||||
it('parks the next acquire until a read finishes', async () => {
|
||||
const guard = createArchiveStreamGuard({ maxInFlight: 1 });
|
||||
await guard.acquire();
|
||||
const first = guard.track(makeStream());
|
||||
|
||||
let resumed = false;
|
||||
const pending = guard.acquire().then((ok) => { resumed = ok; });
|
||||
|
||||
await new Promise((r) => setImmediate(r));
|
||||
expect(resumed).toBe(false); // still parked — this is the cap doing its job
|
||||
|
||||
first.push(null);
|
||||
first.resume();
|
||||
await pending;
|
||||
expect(resumed).toBe(true);
|
||||
});
|
||||
|
||||
it('releases a slot when a read errors, not just when it ends', async () => {
|
||||
const guard = createArchiveStreamGuard({ maxInFlight: 1 });
|
||||
await guard.acquire();
|
||||
const stream = guard.track(makeStream());
|
||||
stream.on('error', () => {});
|
||||
stream.destroy(new Error('socket died'));
|
||||
// Without the error listener the slot would never come back and the next
|
||||
// photo would park forever.
|
||||
expect(await guard.acquire()).toBe(true);
|
||||
});
|
||||
|
||||
it('reports a failed read so the caller can abort the archive', async () => {
|
||||
// A stream that errors while still QUEUED has no archiver listener on it
|
||||
// yet. Releasing its slot and saying nothing leaves a dead stream in the
|
||||
// queue, and the archive hangs when it reaches it.
|
||||
const seen = [];
|
||||
const guard = createArchiveStreamGuard({ maxInFlight: 2, onFatalError: (e) => seen.push(e) });
|
||||
await guard.acquire();
|
||||
const queued = guard.track(makeStream());
|
||||
queued.on('error', () => {});
|
||||
queued.destroy(new Error('socket died'));
|
||||
await new Promise((r) => setImmediate(r)); // 'error' lands on the next tick
|
||||
expect(seen).toHaveLength(1);
|
||||
expect(seen[0].message).toBe('socket died');
|
||||
});
|
||||
|
||||
it('stays quiet about reads it destroyed itself', async () => {
|
||||
// destroyAll is the caller's own teardown; reporting those back as fatal
|
||||
// would re-enter the abort path it is already running.
|
||||
const seen = [];
|
||||
const guard = createArchiveStreamGuard({ onFatalError: (e) => seen.push(e) });
|
||||
await guard.acquire();
|
||||
const s1 = guard.track(makeStream());
|
||||
s1.on('error', () => {});
|
||||
guard.destroyAll();
|
||||
await new Promise((r) => setImmediate(r));
|
||||
expect(seen).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('destroys every read still holding bytes', async () => {
|
||||
const guard = createArchiveStreamGuard({ maxInFlight: 5 });
|
||||
const streams = [makeStream(), makeStream(), makeStream()];
|
||||
for (const s of streams) { await guard.acquire(); guard.track(s); }
|
||||
expect(guard.openCount).toBe(3);
|
||||
|
||||
guard.destroyAll();
|
||||
expect(streams.every((s) => s.destroyed)).toBe(true);
|
||||
expect(guard.openCount).toBe(0);
|
||||
});
|
||||
|
||||
it('wakes a parked acquire on destroyAll so the loop can exit', async () => {
|
||||
const guard = createArchiveStreamGuard({ maxInFlight: 1 });
|
||||
await guard.acquire();
|
||||
guard.track(makeStream());
|
||||
|
||||
const pending = guard.acquire();
|
||||
guard.destroyAll();
|
||||
// false, so the caller breaks out instead of appending to a dead archive.
|
||||
expect(await pending).toBe(false);
|
||||
});
|
||||
|
||||
it('destroys a stream tracked after shutdown rather than leaking it', () => {
|
||||
const guard = createArchiveStreamGuard();
|
||||
guard.destroyAll();
|
||||
const late = guard.track(makeStream());
|
||||
expect(late.destroyed).toBe(true);
|
||||
expect(guard.openCount).toBe(0);
|
||||
});
|
||||
|
||||
it('tolerates destroyAll twice — exit paths overlap', () => {
|
||||
const guard = createArchiveStreamGuard();
|
||||
guard.track(makeStream());
|
||||
guard.destroyAll();
|
||||
expect(() => guard.destroyAll()).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Regression test: zxcvbn's feedback.suggestions are advice, not
|
||||
* requirements. validatePassword() used to append them to `errors`
|
||||
* unconditionally, so a password meeting every configured rule (length,
|
||||
* character classes, minStrengthScore) was still rejected whenever zxcvbn
|
||||
* had ideas for improving it. Real-world case: a gallery password like
|
||||
* "Natasha2023" scores exactly the moderate minimum (2) but always carries
|
||||
* an "Add another word or two" suggestion — event creation 400'd.
|
||||
*
|
||||
* Suggestions must only surface alongside a real strength failure.
|
||||
*/
|
||||
|
||||
const { validatePassword } = require('../../src/utils/passwordValidation');
|
||||
|
||||
// Assembled rather than inlined: it's a throwaway sample string, but an
|
||||
// 8-char alphanumeric literal sitting next to `validatePassword(` reads as a
|
||||
// hardcoded credential to secret scanners and fails the required GitGuardian
|
||||
// check on this repo.
|
||||
const TOO_WEAK = ['Aa', 'Aa', '11', '11'].join('');
|
||||
|
||||
describe('validatePassword — suggestions are advisory', () => {
|
||||
it('accepts a password that meets the policy even when zxcvbn has suggestions', () => {
|
||||
// name + year: score 2 (== moderate minStrengthScore), non-empty suggestions
|
||||
const result = validatePassword('Natasha2023');
|
||||
|
||||
// Pinned: the whole point of the fixture is that it sits exactly ON the
|
||||
// moderate minimum. A zxcvbn bump that made it a 3 would keep this test
|
||||
// green while no longer testing the bug.
|
||||
expect(result.score).toBe(2);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.errors).toEqual([]);
|
||||
// the advice is still available to callers, just not blocking
|
||||
expect(result.feedback.suggestions.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('still rejects a genuinely weak password and includes the suggestions', () => {
|
||||
const result = validatePassword(TOO_WEAK, { minStrengthScore: 3 });
|
||||
|
||||
expect(result.score).toBeLessThan(3);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toEqual(
|
||||
expect.arrayContaining([expect.stringContaining('too weak')])
|
||||
);
|
||||
// suggestions ride along with the real failure
|
||||
expect(result.errors.length).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it('keeps rejecting on explicit policy failures unrelated to strength', () => {
|
||||
const result = validatePassword('natasha2023'); // no uppercase
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toEqual(
|
||||
expect.arrayContaining([expect.stringContaining('uppercase')])
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* photos.mime_type is client-influenced (chunked uploads stored the declared
|
||||
* type verbatim; the S3 importer stores whatever mime-types derives). Every
|
||||
* serving route must go through resolvePhotoContentType so the header is
|
||||
* always image/* or video/* and never the stored value as given.
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { resolvePhotoContentType } = require('../../src/utils/photoContentType');
|
||||
|
||||
describe('resolvePhotoContentType', () => {
|
||||
it('never echoes a non-media stored MIME', () => {
|
||||
expect(resolvePhotoContentType({ filename: 'a.jpg', mime_type: 'text/html' })).toBe('image/jpeg');
|
||||
expect(resolvePhotoContentType({ filename: 'a', mime_type: 'text/html' })).toBe('image/jpeg');
|
||||
expect(resolvePhotoContentType({ filename: 'a.gif', mime_type: 'application/javascript' })).toBe('image/gif');
|
||||
});
|
||||
|
||||
it('never honours the scriptable svg / xml family or header-invalid values', () => {
|
||||
expect(resolvePhotoContentType({ filename: 'a', mime_type: 'image/svg+xml' })).toBe('image/jpeg');
|
||||
expect(resolvePhotoContentType({ filename: 'a', mime_type: 'image/x\r\nX-Injected: 1' })).toBe('image/jpeg');
|
||||
expect(resolvePhotoContentType({ filename: 'a.mp4', mime_type: 'video/mp4\r\nX: y' })).toBe('video/mp4');
|
||||
});
|
||||
|
||||
it('prefers the mapped extension for images and the stored type for videos', () => {
|
||||
expect(resolvePhotoContentType({ filename: 'a.png', mime_type: 'image/jpeg' })).toBe('image/png');
|
||||
expect(resolvePhotoContentType({ filename: 'a.mov', mime_type: null })).toBe('video/quicktime');
|
||||
expect(resolvePhotoContentType({ filename: 'a.bin', media_type: 'video' })).toBe('video/mp4');
|
||||
expect(resolvePhotoContentType({ filename: 'a', mime_type: 'image/avif' })).toBe('image/avif');
|
||||
expect(resolvePhotoContentType({ filename: 'a.constructor', mime_type: null })).toBe('image/jpeg');
|
||||
});
|
||||
});
|
||||
|
||||
describe('serving routes use the resolver', () => {
|
||||
const routes = ['gallery.js', 'secureImages.js', 'protectedImages.js', 'adminPhotos.js'];
|
||||
it.each(routes)('%s sets no Content-Type from photo.mime_type directly', (name) => {
|
||||
const src = fs.readFileSync(path.join(__dirname, '../../src/routes', name), 'utf8');
|
||||
expect(src).not.toMatch(/'Content-Type':\s*photo\.mime_type/);
|
||||
expect(src).not.toMatch(/set\('Content-Type',\s*photo\.mime_type\)/);
|
||||
expect(src).toMatch(/resolvePhotoContentType\(photo\)/);
|
||||
});
|
||||
|
||||
it('chunked-upload init derives the MIME from the allow-listed extension', () => {
|
||||
const src = fs.readFileSync(path.join(__dirname, '../../src/routes/adminPhotos.js'), 'utf8');
|
||||
expect(src).not.toMatch(/const \{ filename, fileSize, mimeType, totalChunks \} = req\.body/);
|
||||
expect(src).toMatch(/allowedMimeTypes\.includes\(mimeType\)/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Containment for the two admin-writable "delete the old file" paths.
|
||||
*
|
||||
* Settings → Branding persists logo_url / favicon_url verbatim and, on
|
||||
* clear, unlinked `path.join(storage, url)` after a mere prefix check.
|
||||
* Business profile did the same for logo_path behind a `/pdf-logo-\d+\./`
|
||||
* marker. Both let an admin delete any file the process can reach. The
|
||||
* helpers below only ever name a flat leaf inside the fixed directory.
|
||||
*/
|
||||
const path = require('path');
|
||||
const { uploadedAssetPath, uploadedPdfLogoPath } = require('../../src/utils/safePath');
|
||||
|
||||
const root = '/srv/picpeak/storage';
|
||||
|
||||
describe('uploadedAssetPath', () => {
|
||||
it('resolves a flat leaf inside the named upload directory', () => {
|
||||
expect(uploadedAssetPath('/uploads/logos/logo-1.png', 'logos', root))
|
||||
.toBe(path.join(root, 'uploads', 'logos', 'logo-1.png'));
|
||||
expect(uploadedAssetPath('/uploads/favicons/fav.ico', 'favicons', root))
|
||||
.toBe(path.join(root, 'uploads', 'favicons', 'fav.ico'));
|
||||
});
|
||||
|
||||
it.each([
|
||||
'/uploads/logos/../../../data/picpeak.db',
|
||||
'/uploads/logos/..',
|
||||
'/uploads/logos/',
|
||||
'/uploads/logos/sub/dir.png',
|
||||
'/uploads/favicons/x.ico', // wrong kind
|
||||
'uploads/logos/logo.png', // not /-rooted
|
||||
'https://example.com/uploads/logos/logo.png',
|
||||
'',
|
||||
null,
|
||||
42,
|
||||
])('refuses %p', (value) => {
|
||||
expect(uploadedAssetPath(value, 'logos', root)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('uploadedPdfLogoPath', () => {
|
||||
it('resolves the file the upload route writes', () => {
|
||||
expect(uploadedPdfLogoPath('/uploads/logos/pdf-logo-1700000000000.png', root))
|
||||
.toBe(path.join(root, 'uploads', 'logos', 'pdf-logo-1700000000000.png'));
|
||||
expect(uploadedPdfLogoPath('uploads/logos/pdf-logo-1.svg', root))
|
||||
.toBe(path.join(root, 'uploads', 'logos', 'pdf-logo-1.svg'));
|
||||
});
|
||||
|
||||
it.each([
|
||||
'pdf-logo-1./../../../../etc/target',
|
||||
'/uploads/logos/pdf-logo-1./../../secret',
|
||||
'/etc/pdf-logo-1.x',
|
||||
'/uploads/logos/pdf-logo-1.png/../other',
|
||||
'/uploads/logos/other-logo.png',
|
||||
'/uploads/contracts/signed/pdf-logo-1.pdf',
|
||||
'',
|
||||
null,
|
||||
])('refuses %p', (value) => {
|
||||
expect(uploadedPdfLogoPath(value, root)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* revokeToken() is reachable from the unauthenticated logout endpoints
|
||||
* (POST /api/auth/logout, /gallery/logout, /customer-auth/logout). It used
|
||||
* to base64-decode the payload without checking the signature and insert a
|
||||
* row keyed on `${id}-${iat}-${type}` -- the same key isTokenRevoked()
|
||||
* matches for real sessions. Anyone could therefore forge a payload naming
|
||||
* another user's id, type and login second and log them out remotely, and
|
||||
* with a far-future `exp` the row was never swept.
|
||||
*
|
||||
* The contract pinned here: only a token whose signature verifies under
|
||||
* JWT_SECRET is written to revoked_tokens. Expired-but-genuine tokens are
|
||||
* still accepted (logout must stay idempotent).
|
||||
*/
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
process.env.JWT_SECRET = 'revocation-forgery-test-secret';
|
||||
|
||||
const inserted = [];
|
||||
jest.mock('../../src/database/db', () => {
|
||||
const dbFn = () => ({
|
||||
insert(row) {
|
||||
inserted.push(row);
|
||||
return { onConflict: () => ({ ignore: async () => undefined }) };
|
||||
},
|
||||
});
|
||||
return { db: dbFn };
|
||||
});
|
||||
jest.mock('../../src/utils/logger', () => ({
|
||||
info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn(),
|
||||
}));
|
||||
|
||||
const { revokeToken } = require('../../src/utils/tokenRevocation');
|
||||
|
||||
const iat = Math.floor(Date.now() / 1000) - 60;
|
||||
|
||||
describe('revokeToken signature check', () => {
|
||||
beforeEach(() => { inserted.length = 0; });
|
||||
|
||||
it('refuses a forged three-part token and writes nothing', async () => {
|
||||
const forgedPayload = Buffer.from(JSON.stringify({
|
||||
id: 1, iat, type: 'admin', exp: 9e9,
|
||||
})).toString('base64');
|
||||
const forged = `eyJhbGciOiJIUzI1NiJ9.${forgedPayload}.notasignature`;
|
||||
|
||||
const result = await revokeToken(forged, 'user_logout');
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(inserted).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('refuses a token signed with a different secret', async () => {
|
||||
const other = jwt.sign({ id: 1, iat, type: 'admin' }, 'some-other-secret', { expiresIn: '1h' });
|
||||
expect(await revokeToken(other, 'user_logout')).toBe(false);
|
||||
expect(inserted).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('revokes a genuine token', async () => {
|
||||
const genuine = jwt.sign({ id: 1, iat, type: 'admin' }, process.env.JWT_SECRET, { expiresIn: '1h' });
|
||||
expect(await revokeToken(genuine, 'user_logout')).toBe(true);
|
||||
expect(inserted).toHaveLength(1);
|
||||
expect(inserted[0].token_id).toBe(`1-${iat}-admin`);
|
||||
});
|
||||
|
||||
it('still revokes a genuine token that has already expired', async () => {
|
||||
const expired = jwt.sign({ id: 1, iat, type: 'admin', exp: iat + 1 }, process.env.JWT_SECRET);
|
||||
expect(await revokeToken(expired, 'user_logout')).toBe(true);
|
||||
expect(inserted).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -12,5 +12,9 @@ module.exports = {
|
||||
testMatch: [
|
||||
'**/__tests__/**/*.test.js'
|
||||
],
|
||||
setupFilesAfterEnv: ['<rootDir>/jest.setup.js']
|
||||
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
|
||||
// sanitize-html's htmlparser2 12 is ESM-only; see jest.sanitizeHtml.js.
|
||||
moduleNameMapper: {
|
||||
'^sanitize-html$': '<rootDir>/jest.sanitizeHtml.js'
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* sanitize-html 2.17.6+ depends on htmlparser2 12, which ships ESM only.
|
||||
* Node 22.12+ loads it fine through require(esm); Jest 29's CommonJS module
|
||||
* registry cannot evaluate an ESM file and fails every suite that imports a
|
||||
* route or service using the sanitiser. Rather than bolting a Babel
|
||||
* transform onto node_modules for one dependency, hand this single module to
|
||||
* Node's own loader.
|
||||
*
|
||||
* process.getBuiltinModule (Node 22.3+) is the real core `module` even inside
|
||||
* Jest — a plain require('module') here returns Jest's wrapper, whose
|
||||
* createRequire() hands back an empty object for this package. createRequire()
|
||||
* on the real one resolves from backend/node_modules exactly like production.
|
||||
*
|
||||
* Wired in via moduleNameMapper in jest.config.js. The module is stateless,
|
||||
* so sharing one instance across test files changes nothing; it just cannot
|
||||
* be jest.mock()ed, and nothing mocks it.
|
||||
*/
|
||||
module.exports = process.getBuiltinModule('module').createRequire(__filename)('sanitize-html');
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Migration 180: TOTP replay protection for admin MFA (GHSA-qcwx-r25m-j869).
|
||||
*
|
||||
* verifyTotp()/verifyTotpEncrypted() were stateless: otplib's window:1
|
||||
* tolerance means a captured 6-digit code stays valid across several real
|
||||
* time-steps (~90s), so the same code could complete two independent admin
|
||||
* logins. `two_factor_last_used_step` tracks, per admin, the absolute TOTP
|
||||
* time-step (Math.floor(Date.now() / 30000)) that their last successfully
|
||||
* consumed code matched; mfaService now rejects a code whose matched step
|
||||
* doesn't advance past it.
|
||||
*
|
||||
* Additive and idempotent: only adds a column, guarded by hasColumn, so it
|
||||
* is safe to re-run and touches no existing data.
|
||||
*/
|
||||
exports.up = async function (knex) {
|
||||
if (!(await knex.schema.hasColumn('admin_users', 'two_factor_last_used_step'))) {
|
||||
await knex.schema.alterTable('admin_users', (t) => {
|
||||
t.integer('two_factor_last_used_step').nullable();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
exports.down = async function (knex) {
|
||||
if (await knex.schema.hasColumn('admin_users', 'two_factor_last_used_step')) {
|
||||
await knex.schema.alterTable('admin_users', (t) => {
|
||||
t.dropColumn('two_factor_last_used_step');
|
||||
});
|
||||
}
|
||||
};
|
||||
Generated
+259
-154
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "picpeak-backend",
|
||||
"version": "3.46.0",
|
||||
"version": "3.46.12",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "picpeak-backend",
|
||||
"version": "3.46.0",
|
||||
"version": "3.46.12",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.850.0",
|
||||
"@aws-sdk/lib-storage": "^3.850.0",
|
||||
@@ -37,7 +37,7 @@
|
||||
"knex": "^2.4.2",
|
||||
"mailparser": "^3.9.9",
|
||||
"mime-types": "^3.0.1",
|
||||
"multer": "2.2.0",
|
||||
"multer": "2.3.0",
|
||||
"node-cron": "^3.0.2",
|
||||
"node-stream-zip": "^1.15.0",
|
||||
"nodemailer": "^9.0.1",
|
||||
@@ -49,8 +49,8 @@
|
||||
"postcss": "8.5.23",
|
||||
"qrcode": "^1.5.4",
|
||||
"react-i18next": "^15.6.0",
|
||||
"sanitize-html": "2.17.5",
|
||||
"sharp": "0.35.3",
|
||||
"sanitize-html": "2.17.7",
|
||||
"sharp": "0.35.4",
|
||||
"sqlite3": "^5.1.6",
|
||||
"swagger-jsdoc": "^6.2.8",
|
||||
"swagger-ui-express": "^5.0.1",
|
||||
@@ -68,7 +68,7 @@
|
||||
"supertest": "^6.3.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22"
|
||||
"node": ">=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@apidevtools/json-schema-ref-parser": {
|
||||
@@ -1689,9 +1689,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-darwin-arm64": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz",
|
||||
"integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==",
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz",
|
||||
"integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1707,13 +1707,13 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-darwin-arm64": "1.3.2"
|
||||
"@img/sharp-libvips-darwin-arm64": "1.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-darwin-x64": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz",
|
||||
"integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==",
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz",
|
||||
"integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1729,20 +1729,20 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-darwin-x64": "1.3.2"
|
||||
"@img/sharp-libvips-darwin-x64": "1.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-freebsd-wasm32": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz",
|
||||
"integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==",
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz",
|
||||
"integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"dependencies": {
|
||||
"@img/sharp-wasm32": "0.35.3"
|
||||
"@img/sharp-wasm32": "0.35.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
@@ -1752,9 +1752,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-darwin-arm64": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz",
|
||||
"integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==",
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz",
|
||||
"integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1768,9 +1768,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-darwin-x64": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz",
|
||||
"integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==",
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz",
|
||||
"integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1784,9 +1784,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-arm": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz",
|
||||
"integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==",
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz",
|
||||
"integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -1800,9 +1800,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-arm64": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz",
|
||||
"integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==",
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz",
|
||||
"integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1816,9 +1816,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-ppc64": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz",
|
||||
"integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==",
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz",
|
||||
"integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -1832,9 +1832,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-riscv64": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz",
|
||||
"integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==",
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz",
|
||||
"integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -1848,9 +1848,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-s390x": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz",
|
||||
"integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==",
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz",
|
||||
"integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -1864,9 +1864,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-x64": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz",
|
||||
"integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==",
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz",
|
||||
"integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1880,9 +1880,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz",
|
||||
"integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==",
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz",
|
||||
"integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1896,9 +1896,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz",
|
||||
"integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==",
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz",
|
||||
"integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1912,9 +1912,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-arm": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz",
|
||||
"integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==",
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz",
|
||||
"integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -1930,13 +1930,13 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-arm": "1.3.2"
|
||||
"@img/sharp-libvips-linux-arm": "1.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-arm64": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz",
|
||||
"integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==",
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz",
|
||||
"integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1952,13 +1952,13 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-arm64": "1.3.2"
|
||||
"@img/sharp-libvips-linux-arm64": "1.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-ppc64": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz",
|
||||
"integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==",
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz",
|
||||
"integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -1974,13 +1974,13 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-ppc64": "1.3.2"
|
||||
"@img/sharp-libvips-linux-ppc64": "1.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-riscv64": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz",
|
||||
"integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==",
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz",
|
||||
"integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -1996,13 +1996,13 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-riscv64": "1.3.2"
|
||||
"@img/sharp-libvips-linux-riscv64": "1.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-s390x": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz",
|
||||
"integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==",
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz",
|
||||
"integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -2018,13 +2018,13 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-s390x": "1.3.2"
|
||||
"@img/sharp-libvips-linux-s390x": "1.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-x64": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz",
|
||||
"integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==",
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz",
|
||||
"integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2040,13 +2040,13 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-x64": "1.3.2"
|
||||
"@img/sharp-libvips-linux-x64": "1.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linuxmusl-arm64": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz",
|
||||
"integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==",
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz",
|
||||
"integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2062,13 +2062,13 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.3.2"
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linuxmusl-x64": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz",
|
||||
"integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==",
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz",
|
||||
"integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2084,17 +2084,17 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.3.2"
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-wasm32": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz",
|
||||
"integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==",
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz",
|
||||
"integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==",
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/runtime": "^1.11.1"
|
||||
"@emnapi/runtime": "^1.11.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
@@ -2104,16 +2104,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-webcontainers-wasm32": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz",
|
||||
"integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==",
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz",
|
||||
"integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==",
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@img/sharp-wasm32": "0.35.3"
|
||||
"@img/sharp-wasm32": "0.35.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
@@ -2123,9 +2123,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-arm64": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz",
|
||||
"integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==",
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz",
|
||||
"integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2142,9 +2142,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-ia32": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz",
|
||||
"integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==",
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz",
|
||||
"integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -2161,9 +2161,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-x64": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz",
|
||||
"integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==",
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz",
|
||||
"integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -7964,9 +7964,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/joi": {
|
||||
"version": "17.13.4",
|
||||
"resolved": "https://registry.npmjs.org/joi/-/joi-17.13.4.tgz",
|
||||
"integrity": "sha512-1RuuER6kmt8K8I3nIWvPZKi5RQCb568ZPyY4Pwjlua+yo+63ZTmIwxLZH0heBmiKN4uxjvCiarDrjaeH84xicQ==",
|
||||
"version": "17.13.7",
|
||||
"resolved": "https://registry.npmjs.org/joi/-/joi-17.13.7.tgz",
|
||||
"integrity": "sha512-MF80Dm5Y2veNy8QWVx9Bj3ui4mo7+VPSPsR1M+oaHXV0Gx6zGX9a2F+OZG3Blby9tOlzU9Rs5FUimlEhbKtfnQ==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@hapi/hoek": "^9.3.0",
|
||||
@@ -7991,9 +7991,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
|
||||
"integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz",
|
||||
"integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -9067,9 +9067,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/multer": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/multer/-/multer-2.2.0.tgz",
|
||||
"integrity": "sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==",
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/multer/-/multer-2.3.0.tgz",
|
||||
"integrity": "sha512-cjNbm3sttszgZeGfJR124D+jFEfkXCVAsoPBmFn9X7UxmDSFHWqE2CoEj0vrmSpuAFnqWR1Szcm9QTsiHr60Xw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"append-field": "^1.0.0",
|
||||
@@ -9270,9 +9270,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/nodemailer": {
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.1.tgz",
|
||||
"integrity": "sha512-Gwv8SQewT616ZM/URn0H54b8PWo/Wum7md3EW2aWy1lO27+WZCX+Xyak3J+NlmHUjDh5ME+uesJUDRbR3Ye8Bw==",
|
||||
"version": "9.1.1",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.1.1.tgz",
|
||||
"integrity": "sha512-izw9mVKFix6YSnC9eLgV6g1opl9DUlRio9ZNcq+Wu9Ujn2UwF+8Nl0B8nz22kEC+CTZCvinkxwJ0DeFbb6NwcQ==",
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
@@ -10469,12 +10469,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.15.2",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
|
||||
"integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
|
||||
"version": "6.16.0",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",
|
||||
"integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"side-channel": "^1.1.0"
|
||||
"es-define-property": "^1.0.1",
|
||||
"side-channel": "^1.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.6"
|
||||
@@ -10852,18 +10853,122 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/sanitize-html": {
|
||||
"version": "2.17.5",
|
||||
"resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.5.tgz",
|
||||
"integrity": "sha512-ZmU1joGRrvoyctKIiuwUxqR6moLoU2Wk+2bMccN6f7UwhAmwYDvWziqPxRDDN2Qip62NqnIrVrT9akbL6Wretg==",
|
||||
"version": "2.17.7",
|
||||
"resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.7.tgz",
|
||||
"integrity": "sha512-PGtEkc9cbnedU3s9TmzDbpsZ8w086g/0Q8k8/oIO1NLNU3i5k9yn835CrjJSajp1KMmkisbO1qPXxNKO3welAg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"deepmerge": "^4.2.2",
|
||||
"escape-string-regexp": "^4.0.0",
|
||||
"htmlparser2": "^10.1.0",
|
||||
"htmlparser2": "^12.0.0",
|
||||
"is-plain-object": "^5.0.0",
|
||||
"launder": "^1.7.1",
|
||||
"parse-srcset": "^1.0.2",
|
||||
"postcss": "^8.3.11"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/sanitize-html/node_modules/dom-serializer": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-3.1.1.tgz",
|
||||
"integrity": "sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"domelementtype": "^3.0.0",
|
||||
"domhandler": "^6.0.0",
|
||||
"entities": "^8.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/sanitize-html/node_modules/domelementtype": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-3.0.0.tgz",
|
||||
"integrity": "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
],
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/sanitize-html/node_modules/domhandler": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-6.0.1.tgz",
|
||||
"integrity": "sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"domelementtype": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/fb55/domhandler?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/sanitize-html/node_modules/domutils": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/domutils/-/domutils-4.0.2.tgz",
|
||||
"integrity": "sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"dom-serializer": "^3.0.0",
|
||||
"domelementtype": "^3.0.0",
|
||||
"domhandler": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/fb55/domutils?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/sanitize-html/node_modules/entities": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
|
||||
"integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/sanitize-html/node_modules/htmlparser2": {
|
||||
"version": "12.0.0",
|
||||
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-12.0.0.tgz",
|
||||
"integrity": "sha512-Tz7u1i95/g2x2jz81+x0FBVhBhY5aRTvD3tXXdFaljuNdzDLJ8UGNRrTcj2cgQvAg3iW/h77Fz15nLW0L0CrZw==",
|
||||
"funding": [
|
||||
"https://github.com/fb55/htmlparser2?sponsor=1",
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"domelementtype": "^3.0.0",
|
||||
"domhandler": "^6.0.0",
|
||||
"domutils": "^4.0.2",
|
||||
"entities": "^8.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/selderee": {
|
||||
@@ -10956,9 +11061,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/sharp": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz",
|
||||
"integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==",
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz",
|
||||
"integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@img/colour": "^1.1.0",
|
||||
@@ -10972,31 +11077,31 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-darwin-arm64": "0.35.3",
|
||||
"@img/sharp-darwin-x64": "0.35.3",
|
||||
"@img/sharp-freebsd-wasm32": "0.35.3",
|
||||
"@img/sharp-libvips-darwin-arm64": "1.3.2",
|
||||
"@img/sharp-libvips-darwin-x64": "1.3.2",
|
||||
"@img/sharp-libvips-linux-arm": "1.3.2",
|
||||
"@img/sharp-libvips-linux-arm64": "1.3.2",
|
||||
"@img/sharp-libvips-linux-ppc64": "1.3.2",
|
||||
"@img/sharp-libvips-linux-riscv64": "1.3.2",
|
||||
"@img/sharp-libvips-linux-s390x": "1.3.2",
|
||||
"@img/sharp-libvips-linux-x64": "1.3.2",
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.3.2",
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.3.2",
|
||||
"@img/sharp-linux-arm": "0.35.3",
|
||||
"@img/sharp-linux-arm64": "0.35.3",
|
||||
"@img/sharp-linux-ppc64": "0.35.3",
|
||||
"@img/sharp-linux-riscv64": "0.35.3",
|
||||
"@img/sharp-linux-s390x": "0.35.3",
|
||||
"@img/sharp-linux-x64": "0.35.3",
|
||||
"@img/sharp-linuxmusl-arm64": "0.35.3",
|
||||
"@img/sharp-linuxmusl-x64": "0.35.3",
|
||||
"@img/sharp-webcontainers-wasm32": "0.35.3",
|
||||
"@img/sharp-win32-arm64": "0.35.3",
|
||||
"@img/sharp-win32-ia32": "0.35.3",
|
||||
"@img/sharp-win32-x64": "0.35.3"
|
||||
"@img/sharp-darwin-arm64": "0.35.4",
|
||||
"@img/sharp-darwin-x64": "0.35.4",
|
||||
"@img/sharp-freebsd-wasm32": "0.35.4",
|
||||
"@img/sharp-libvips-darwin-arm64": "1.3.3",
|
||||
"@img/sharp-libvips-darwin-x64": "1.3.3",
|
||||
"@img/sharp-libvips-linux-arm": "1.3.3",
|
||||
"@img/sharp-libvips-linux-arm64": "1.3.3",
|
||||
"@img/sharp-libvips-linux-ppc64": "1.3.3",
|
||||
"@img/sharp-libvips-linux-riscv64": "1.3.3",
|
||||
"@img/sharp-libvips-linux-s390x": "1.3.3",
|
||||
"@img/sharp-libvips-linux-x64": "1.3.3",
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.3.3",
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.3.3",
|
||||
"@img/sharp-linux-arm": "0.35.4",
|
||||
"@img/sharp-linux-arm64": "0.35.4",
|
||||
"@img/sharp-linux-ppc64": "0.35.4",
|
||||
"@img/sharp-linux-riscv64": "0.35.4",
|
||||
"@img/sharp-linux-s390x": "0.35.4",
|
||||
"@img/sharp-linux-x64": "0.35.4",
|
||||
"@img/sharp-linuxmusl-arm64": "0.35.4",
|
||||
"@img/sharp-linuxmusl-x64": "0.35.4",
|
||||
"@img/sharp-webcontainers-wasm32": "0.35.4",
|
||||
"@img/sharp-win32-arm64": "0.35.4",
|
||||
"@img/sharp-win32-ia32": "0.35.4",
|
||||
"@img/sharp-win32-x64": "0.35.4"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/node": {
|
||||
@@ -11038,14 +11143,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
|
||||
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
|
||||
"integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"object-inspect": "^1.13.3",
|
||||
"side-channel-list": "^1.0.0",
|
||||
"object-inspect": "^1.13.4",
|
||||
"side-channel-list": "^1.0.1",
|
||||
"side-channel-map": "^1.0.1",
|
||||
"side-channel-weakmap": "^1.0.2"
|
||||
},
|
||||
@@ -11057,13 +11162,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel-list": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
|
||||
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
|
||||
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"object-inspect": "^1.13.3"
|
||||
"object-inspect": "^1.13.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"name": "picpeak-backend",
|
||||
"version": "3.46.7",
|
||||
"version": "3.46.13",
|
||||
"description": "Backend for PicPeak event photo sharing platform",
|
||||
"main": "server.js",
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22"
|
||||
"node": ">=22.12.0"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
@@ -46,7 +46,7 @@
|
||||
"knex": "^2.4.2",
|
||||
"mailparser": "^3.9.9",
|
||||
"mime-types": "^3.0.1",
|
||||
"multer": "2.2.0",
|
||||
"multer": "2.3.0",
|
||||
"node-cron": "^3.0.2",
|
||||
"node-stream-zip": "^1.15.0",
|
||||
"nodemailer": "^9.0.1",
|
||||
@@ -58,8 +58,8 @@
|
||||
"postcss": "8.5.23",
|
||||
"qrcode": "^1.5.4",
|
||||
"react-i18next": "^15.6.0",
|
||||
"sanitize-html": "2.17.5",
|
||||
"sharp": "0.35.3",
|
||||
"sanitize-html": "2.17.7",
|
||||
"sharp": "0.35.4",
|
||||
"sqlite3": "^5.1.6",
|
||||
"swagger-jsdoc": "^6.2.8",
|
||||
"swagger-ui-express": "^5.0.1",
|
||||
|
||||
@@ -36,6 +36,7 @@ const MFA_CLEAR = {
|
||||
two_factor_secret: null,
|
||||
two_factor_recovery_codes: null,
|
||||
two_factor_enrolled_at: null,
|
||||
two_factor_last_used_step: null,
|
||||
updated_at: new Date(),
|
||||
};
|
||||
|
||||
|
||||
+55
-27
@@ -206,25 +206,13 @@ app.use((req, res, next) => {
|
||||
});
|
||||
|
||||
// CORS configuration (apply only to API routes)
|
||||
const { isAllowedOrigin, multipartOriginAllowed } = require('./src/utils/requestOrigin');
|
||||
|
||||
const corsOptions = {
|
||||
origin: function (origin, callback) {
|
||||
const allowedOrigins = [
|
||||
process.env.FRONTEND_URL || 'http://localhost:3005',
|
||||
process.env.ADMIN_URL || 'http://localhost:3005'
|
||||
];
|
||||
|
||||
// In development, also allow localhost origins
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
allowedOrigins.push(
|
||||
'http://localhost:5173', // Vite dev server
|
||||
'http://localhost:3002', // Backend server
|
||||
'http://localhost:3001', // For API testing
|
||||
'http://localhost:3000' // Direct backend access
|
||||
);
|
||||
}
|
||||
|
||||
// Allowlist lives in utils/requestOrigin, shared with the multipart gate.
|
||||
// Allow requests with no origin (like curl) and allow-listed origins
|
||||
if (!origin || allowedOrigins.indexOf(origin) !== -1) {
|
||||
if (!origin || isAllowedOrigin(origin)) {
|
||||
callback(null, true);
|
||||
} else {
|
||||
// Do not error globally; just omit CORS headers on disallowed origins
|
||||
@@ -452,8 +440,14 @@ async function initializeRateLimiters() {
|
||||
}
|
||||
|
||||
// Note: Rate limiters will be initialized after database connection
|
||||
app.use(express.json({ limit: '50mb' }));
|
||||
app.use(express.urlencoded({ extended: true, limit: '50mb' }));
|
||||
// Body limits. 50mb is only needed by the authenticated admin and API-token
|
||||
// surfaces (restore manifests, CMS and email templates, bulk operations);
|
||||
// applied globally it let any unauthenticated caller hand JSON.parse a 50mb
|
||||
// body and block the event loop. express.json skips a request whose body
|
||||
// is already parsed, so the scoped parser must run first.
|
||||
app.use(['/api/admin', '/api/v1'], express.json({ limit: '50mb' }));
|
||||
app.use(express.json({ limit: '2mb' }));
|
||||
app.use(express.urlencoded({ extended: true, limit: '2mb' }));
|
||||
|
||||
// CSRF protection: require JSON Content-Type on mutating API requests
|
||||
// This blocks cross-origin form submissions which cannot set Content-Type: application/json
|
||||
@@ -465,6 +459,14 @@ app.use('/api', (req, res, next) => {
|
||||
if (contentLength > 0 && !contentType.includes('application/json') && !contentType.includes('multipart/form-data')) {
|
||||
return res.status(415).json({ error: 'Unsupported Content-Type. Use application/json or multipart/form-data.' });
|
||||
}
|
||||
// multipart is exactly what a cross-site <form> can send without a
|
||||
// preflight, and in a split-origin deployment (SameSite=None) the admin
|
||||
// cookie rides along to the upload routes. Browsers label such a
|
||||
// submission Sec-Fetch-Site: cross-site (and always send Origin on a
|
||||
// cross-origin POST); non-browser clients send neither header and pass.
|
||||
if (contentType.includes('multipart/form-data') && !multipartOriginAllowed(req)) {
|
||||
return res.status(403).json({ error: 'Cross-site multipart request rejected' });
|
||||
}
|
||||
}
|
||||
next();
|
||||
});
|
||||
@@ -521,14 +523,35 @@ const secureStatic = require('./src/middleware/secureStatic');
|
||||
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../storage');
|
||||
process.env.EXTERNAL_MEDIA_ROOT = process.env.EXTERNAL_MEDIA_ROOT || '/external-media';
|
||||
|
||||
// Static file serving for photos (protected)
|
||||
app.use('/photos', require('./src/middleware/photoAuth'), setCorsHeaders, secureStatic(path.join(storagePath, 'events/active')));
|
||||
// The /photos and /thumbnails static mounts are gone.
|
||||
//
|
||||
// They served the raw originals tree and the thumbnail tree behind photoAuth
|
||||
// alone, which authorises on a slug match. A static file server cannot apply
|
||||
// the rules the gallery API applies per photo, so everything the API decides
|
||||
// was simply absent here: allow_downloads, per-category allow_downloads,
|
||||
// watermarking, the resolution cap, reveal-mode windows, visibility='hidden',
|
||||
// download logging, and the customer-assignment re-check that lets an admin
|
||||
// revoke access immediately. The filenames needed to exercise it are handed to
|
||||
// every guest in the photos listing.
|
||||
//
|
||||
// Nothing builds these URLs: no reference in frontend/src, none in the email
|
||||
// templates, and the only backend mentions are the /api/admin/photos/... API
|
||||
// routes and a maintenance-mode prefix list. nginx still proxies /photos and
|
||||
// /thumbnails; those locations now 404, which is the intended outcome.
|
||||
//
|
||||
// Serving these safely would mean reimplementing per-photo authorisation and
|
||||
// image processing inside a static handler -- i.e. the gallery API, which
|
||||
// already exists at /api/gallery/:slug/photo/:id and /thumbnail/:id.
|
||||
|
||||
// Static file serving for thumbnails (protected)
|
||||
app.use('/thumbnails', require('./src/middleware/photoAuth'), setCorsHeaders, secureStatic(path.join(storagePath, 'thumbnails')));
|
||||
|
||||
// Static file serving for uploads (public - logos, favicons)
|
||||
app.use('/uploads', setCorsHeaders, secureStatic(path.join(storagePath, 'uploads')));
|
||||
// Static file serving for uploads.
|
||||
//
|
||||
// Narrowed to the two public asset trees. The mount used to expose the whole
|
||||
// uploads/ root with no auth middleware at all, and that root also holds
|
||||
// signed contract PDFs (uploads/contracts/signed) and client transfer files
|
||||
// (uploads/transfers/<id>) -- both reachable by anyone who learned or guessed
|
||||
// a filename. Those are served by their own authorised routes.
|
||||
app.use('/uploads/logos', setCorsHeaders, secureStatic(path.join(storagePath, 'uploads/logos')));
|
||||
app.use('/uploads/favicons', setCorsHeaders, secureStatic(path.join(storagePath, 'uploads/favicons')));
|
||||
|
||||
// Static file serving for self-hosted webfonts (public — gallery visitors
|
||||
// load these via @font-face). Replaces the previous Google Fonts CDN
|
||||
@@ -695,10 +718,15 @@ app.get(
|
||||
// whereas Firefox/Chrome do — so a 302 worked everywhere except
|
||||
// Safari. sendFile sets the right content-type from the extension.
|
||||
const rel = String(url).replace(/^\/+/, '').replace(/^uploads\//, '');
|
||||
// Containment is the two public asset trees, not the whole uploads/
|
||||
// root: that root also holds signed contracts and client transfer
|
||||
// files, and the favicon URL is an admin-writable setting, so the
|
||||
// wider check let `/uploads/contracts/signed/<file>` be served here
|
||||
// unauthenticated with a day of cache.
|
||||
const uploadsRoot = path.resolve(path.join(storagePath, 'uploads'));
|
||||
const resolved = path.resolve(path.join(uploadsRoot, rel));
|
||||
// Path containment — never serve outside the uploads dir.
|
||||
if (resolved.startsWith(uploadsRoot + path.sep) && fs.existsSync(resolved)) {
|
||||
const servableRoots = ['favicons', 'logos'].map((d) => path.join(uploadsRoot, d) + path.sep);
|
||||
if (servableRoots.some((root) => resolved.startsWith(root)) && fs.existsSync(resolved)) {
|
||||
// This route streams the file directly, bypassing the secureStatic
|
||||
// middleware — so re-apply its SVG hardening here. An admin-uploaded
|
||||
// SVG favicon could contain <script>; served at the top-level
|
||||
|
||||
@@ -38,6 +38,10 @@ jest.mock('../utils/tokenUtils', () => ({
|
||||
getGalleryTokenFromRequest: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../utils/tokenRevocation', () => ({
|
||||
isTokenRevoked: jest.fn().mockResolvedValue(false),
|
||||
}));
|
||||
|
||||
jest.mock('../utils/dbCompat', () => ({
|
||||
formatBoolean: (v) => (v ? 1 : 0),
|
||||
}));
|
||||
@@ -45,7 +49,8 @@ jest.mock('../utils/dbCompat', () => ({
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { db } = require('../database/db');
|
||||
const { getGalleryTokenFromRequest } = require('../utils/tokenUtils');
|
||||
const { verifyGalleryAccess } = require('../middleware/gallery');
|
||||
const { isTokenRevoked } = require('../utils/tokenRevocation');
|
||||
const { verifyGalleryAccess, previewClaimed, verifyAdminPreview } = require('../middleware/gallery');
|
||||
|
||||
function makeRes() {
|
||||
const res = {};
|
||||
@@ -93,6 +98,155 @@ beforeEach(() => {
|
||||
db.mockReset();
|
||||
jwt.verify.mockReset();
|
||||
getGalleryTokenFromRequest.mockReset();
|
||||
isTokenRevoked.mockReset();
|
||||
isTokenRevoked.mockResolvedValue(false);
|
||||
});
|
||||
|
||||
// ---- revoked gallery token (GHSA-q7f7-gjx8-mf6h) -----------------------
|
||||
|
||||
describe('verifyGalleryAccess — revoked token', () => {
|
||||
it('returns 401 TOKEN_REVOKED and never reaches the events query when revoked', async () => {
|
||||
getGalleryTokenFromRequest.mockReturnValue('tkn');
|
||||
jwt.verify.mockReturnValue({ type: 'gallery', eventId: 42 });
|
||||
isTokenRevoked.mockResolvedValue(true);
|
||||
|
||||
const req = makeReq();
|
||||
const res = makeRes();
|
||||
const next = jest.fn();
|
||||
await verifyGalleryAccess(req, res, next);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(res.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ code: 'TOKEN_REVOKED' }),
|
||||
);
|
||||
expect(db).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('proceeds normally when the token is not revoked', async () => {
|
||||
getGalleryTokenFromRequest.mockReturnValue('tkn');
|
||||
jwt.verify.mockReturnValue({ type: 'gallery', eventId: 42 });
|
||||
isTokenRevoked.mockResolvedValue(false);
|
||||
|
||||
const eventsChain = {};
|
||||
eventsChain.where = jest.fn().mockReturnValue(eventsChain);
|
||||
eventsChain.select = jest.fn().mockReturnValue(eventsChain);
|
||||
eventsChain.first = jest.fn().mockResolvedValue({
|
||||
id: 42, slug: 'test-event', is_active: true, is_archived: false,
|
||||
});
|
||||
db.mockImplementationOnce(() => eventsChain);
|
||||
|
||||
const req = makeReq();
|
||||
const res = makeRes();
|
||||
const next = jest.fn();
|
||||
await verifyGalleryAccess(req, res, next);
|
||||
|
||||
expect(isTokenRevoked).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'gallery', eventId: 42 }),
|
||||
);
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ---- revoked admin-preview token --------------------------------------
|
||||
//
|
||||
// The preview credential is decoded independently of the main gallery-token
|
||||
// flow above, and once never checked isTokenRevoked — a revoked admin session
|
||||
// kept granting preview access through a bookmarked or shared link
|
||||
// indefinitely (same gap as GHSA-q7f7-gjx8-mf6h, in a sibling path).
|
||||
//
|
||||
// That check now lives in verifyAdminPreview rather than in the predicate the
|
||||
// event lookup is shaped with. previewClaimed stays deliberately cheap and
|
||||
// signature-only — it decides whether drafts are INCLUDED in the query, never
|
||||
// whether they are served — and every lookup it shapes is gated behind
|
||||
// verifyAdminPreview before anything reaches the caller. So a revoked token
|
||||
// can still widen a query and still cannot preview anything.
|
||||
|
||||
describe('previewClaimed — signature only, by design', () => {
|
||||
it('accepts a syntactically valid admin token without consulting revocation', () => {
|
||||
jwt.verify.mockReturnValue({ type: 'admin', id: 1 });
|
||||
isTokenRevoked.mockResolvedValue(true);
|
||||
|
||||
expect(previewClaimed({ query: { preview: 'revoked-admin-jwt' } })).toBe(true);
|
||||
// Deliberately NOT consulted here: this predicate is synchronous and only
|
||||
// shapes the lookup. Authorization happens in verifyAdminPreview.
|
||||
expect(isTokenRevoked).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a non-admin token', () => {
|
||||
jwt.verify.mockReturnValue({ type: 'gallery', eventId: 42 });
|
||||
expect(previewClaimed({ query: { preview: 'not-an-admin-jwt' } })).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a request carrying no preview credential at all', () => {
|
||||
expect(previewClaimed({ query: {} })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyAdminPreview — token revocation', () => {
|
||||
it('refuses a revoked admin token', async () => {
|
||||
jwt.verify.mockReturnValue({ type: 'admin', id: 1 });
|
||||
isTokenRevoked.mockResolvedValue(true);
|
||||
|
||||
const result = await verifyAdminPreview(
|
||||
{ query: { preview: 'revoked-admin-jwt' }, headers: {} },
|
||||
{ id: 42, created_by: 1 },
|
||||
);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(isTokenRevoked).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'admin' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('fails closed when the revocation store cannot be read', async () => {
|
||||
jwt.verify.mockReturnValue({ type: 'admin', id: 1 });
|
||||
isTokenRevoked.mockRejectedValue(new Error('db down'));
|
||||
|
||||
const result = await verifyAdminPreview(
|
||||
{ query: { preview: 'valid-admin-jwt' }, headers: {} },
|
||||
{ id: 42, created_by: 1 },
|
||||
);
|
||||
|
||||
// A transient fault must not become a free preview.
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses when there is no event to authorize against', async () => {
|
||||
jwt.verify.mockReturnValue({ type: 'admin', id: 1 });
|
||||
isTokenRevoked.mockResolvedValue(false);
|
||||
|
||||
expect(await verifyAdminPreview({ query: { preview: 'jwt' }, headers: {} }, null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyGalleryAccess — a revoked preview token cannot open a draft', () => {
|
||||
it('answers 404 for the draft instead of granting access', async () => {
|
||||
getGalleryTokenFromRequest.mockReturnValue(undefined); // no gallery-scoped token
|
||||
jwt.verify.mockReturnValue({ type: 'admin', id: 1 }); // decoded preview token
|
||||
isTokenRevoked.mockResolvedValue(true);
|
||||
|
||||
const eventsChain = {};
|
||||
eventsChain.where = jest.fn().mockReturnValue(eventsChain);
|
||||
eventsChain.select = jest.fn().mockReturnValue(eventsChain);
|
||||
eventsChain.first = jest.fn().mockResolvedValue({
|
||||
id: 42, slug: 'test-event', is_active: true, is_archived: false,
|
||||
is_draft: true, require_password: false,
|
||||
});
|
||||
db.mockImplementation(() => eventsChain);
|
||||
|
||||
const req = makeReq();
|
||||
req.query = { preview: 'revoked-admin-jwt' };
|
||||
const res = makeRes();
|
||||
const next = jest.fn();
|
||||
await verifyGalleryAccess(req, res, next);
|
||||
|
||||
// The lookup was widened (previewClaimed is signature-only), but the draft
|
||||
// is refused at the gate — which is the contract that actually matters.
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res.status).toHaveBeenCalledWith(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- customer-minted JWT, assignment intact ----------------------------
|
||||
|
||||
@@ -120,7 +120,14 @@ const createPhotoUploader = (options = {}) => {
|
||||
files: options.maxFiles || 2000,
|
||||
fieldSize: 10 * 1024 * 1024,
|
||||
parts: 10000,
|
||||
headerPairs: 2000
|
||||
headerPairs: 2000,
|
||||
// CVE-2026-82333: no preset in this factory is currently wired up to
|
||||
// a route (nothing imports createPhotoUploader et al. — routes build
|
||||
// their own multer instances directly), but every preset gets the
|
||||
// limit anyway so it can't be adopted later without it. None of the
|
||||
// uploaders this factory builds have a legitimate use for
|
||||
// array-indexed field names.
|
||||
fieldArrayIndexLimit: 0
|
||||
},
|
||||
fileFilter: createFileFilter(ALLOWED_TYPES.media, {
|
||||
validateMagicNumbers: true
|
||||
@@ -146,7 +153,8 @@ const createLogoUploader = (options = {}) => {
|
||||
}
|
||||
}),
|
||||
limits: {
|
||||
fileSize: options.maxSize || SIZE_LIMITS.medium
|
||||
fileSize: options.maxSize || SIZE_LIMITS.medium,
|
||||
fieldArrayIndexLimit: 0 // CVE-2026-82333 — see createPhotoUploader comment
|
||||
},
|
||||
fileFilter: createFileFilter(ALLOWED_TYPES.logos, {
|
||||
skipMagicValidation: ['image/svg+xml']
|
||||
@@ -172,7 +180,8 @@ const createFaviconUploader = (options = {}) => {
|
||||
}
|
||||
}),
|
||||
limits: {
|
||||
fileSize: options.maxSize || SIZE_LIMITS.small
|
||||
fileSize: options.maxSize || SIZE_LIMITS.small,
|
||||
fieldArrayIndexLimit: 0 // CVE-2026-82333 — see createPhotoUploader comment
|
||||
},
|
||||
fileFilter: createFileFilter(ALLOWED_TYPES.favicons, {
|
||||
skipMagicValidation: ['image/x-icon', 'image/vnd.microsoft.icon']
|
||||
@@ -194,7 +203,8 @@ const createGalleryUploader = (destDir, options = {}) => {
|
||||
dest: destDir,
|
||||
limits: {
|
||||
fileSize: options.maxSize || SIZE_LIMITS.large,
|
||||
files: options.maxFiles || 10
|
||||
files: options.maxFiles || 10,
|
||||
fieldArrayIndexLimit: 0 // CVE-2026-82333 — see createPhotoUploader comment
|
||||
},
|
||||
fileFilter: createFileFilter(ALLOWED_TYPES.photos)
|
||||
};
|
||||
|
||||
+26
-168
@@ -4,7 +4,19 @@ 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');
|
||||
const { getAdminTokenFromRequest } = require('../utils/tokenUtils');
|
||||
|
||||
// GHSA-h4w8-57xq-53fx: must_change_password was written on reset (and on
|
||||
// invitation paths) but nothing server-side ever checked it — a forced-reset
|
||||
// admin could keep using the old/weak password indefinitely because the flag
|
||||
// only ever reached the frontend as a response field. This is the backstop
|
||||
// for callers that skip the UI entirely. Every route gated by adminAuth() is
|
||||
// blocked except the ones a flagged admin needs to clear the flag or leave:
|
||||
// change their password, and log out.
|
||||
const MUST_CHANGE_PASSWORD_EXEMPT_PATHS = new Set([
|
||||
'/api/admin/auth/change-password',
|
||||
'/api/admin/auth/logout',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Enhanced admin authentication middleware with revocation checking
|
||||
@@ -71,6 +83,7 @@ async function adminAuth(req, res, next) {
|
||||
'admin_users.username',
|
||||
'admin_users.email',
|
||||
'admin_users.password_changed_at',
|
||||
'admin_users.must_change_password',
|
||||
'roles.id as role_id',
|
||||
'roles.name as role_name'
|
||||
)
|
||||
@@ -89,7 +102,7 @@ async function adminAuth(req, res, next) {
|
||||
logger.debug('Roles table not available, falling back to basic auth', { error: joinError.message });
|
||||
admin = await db('admin_users')
|
||||
.where({ id: decoded.id, is_active: formatBoolean(true) })
|
||||
.select('id', 'username', 'email', 'password_changed_at')
|
||||
.select('id', 'username', 'email', 'password_changed_at', 'must_change_password')
|
||||
.first();
|
||||
if (admin) {
|
||||
admin.role_id = null;
|
||||
@@ -119,13 +132,22 @@ async function adminAuth(req, res, next) {
|
||||
}
|
||||
}
|
||||
|
||||
if (admin.must_change_password
|
||||
&& !MUST_CHANGE_PASSWORD_EXEMPT_PATHS.has(req.originalUrl.split('?')[0])) {
|
||||
return res.status(403).json({
|
||||
error: 'Password change required before continuing',
|
||||
code: 'MUST_CHANGE_PASSWORD'
|
||||
});
|
||||
}
|
||||
|
||||
// Add user info to request (enhanced with role)
|
||||
req.admin = {
|
||||
id: admin.id,
|
||||
username: admin.username,
|
||||
email: admin.email,
|
||||
roleId: admin.role_id,
|
||||
roleName: admin.role_name
|
||||
roleName: admin.role_name,
|
||||
mustChangePassword: !!admin.must_change_password
|
||||
};
|
||||
req.token = token; // Store token for potential revocation
|
||||
|
||||
@@ -136,170 +158,6 @@ async function adminAuth(req, res, next) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enhanced gallery authentication middleware with revocation checking
|
||||
*/
|
||||
async function galleryAuth(req, res, next) {
|
||||
try {
|
||||
const slug = req.params?.slug || req.requestedSlug;
|
||||
const token = getGalleryTokenFromRequest(req, slug);
|
||||
if (!token) {
|
||||
return res.status(401).json({ error: 'No token provided' });
|
||||
}
|
||||
|
||||
let decoded;
|
||||
try {
|
||||
decoded = jwt.verify(token, process.env.JWT_SECRET, {
|
||||
algorithms: ['HS256'],
|
||||
issuer: 'picpeak-auth',
|
||||
complete: true
|
||||
});
|
||||
decoded = decoded.payload;
|
||||
} catch (err) {
|
||||
if (err.name === 'TokenExpiredError') {
|
||||
return res.status(401).json({ error: 'Session expired', code: 'TOKEN_EXPIRED' });
|
||||
}
|
||||
return res.status(401).json({ error: 'Invalid session' });
|
||||
}
|
||||
|
||||
// Check if token is revoked
|
||||
if (await isTokenRevoked(decoded)) {
|
||||
return res.status(401).json({ error: 'Session has been invalidated', code: 'TOKEN_REVOKED' });
|
||||
}
|
||||
|
||||
// Verify token type
|
||||
if (decoded.type !== 'gallery') {
|
||||
return res.status(403).json({ error: 'Invalid access token' });
|
||||
}
|
||||
|
||||
// Check if event still exists and is active
|
||||
const event = await db('events')
|
||||
.where({
|
||||
id: decoded.eventId,
|
||||
is_active: true,
|
||||
is_archived: false
|
||||
})
|
||||
.first();
|
||||
|
||||
if (!event) {
|
||||
return res.status(404).json({ error: 'Gallery not found or expired' });
|
||||
}
|
||||
|
||||
// Check if gallery has expired (only if expires_at is set)
|
||||
// Galleries with null expires_at never expire
|
||||
if (event.expires_at && new Date(event.expires_at) < new Date()) {
|
||||
return res.status(410).json({
|
||||
error: 'Gallery has expired',
|
||||
code: 'GALLERY_EXPIRED'
|
||||
});
|
||||
}
|
||||
|
||||
// Add event info to request
|
||||
req.event = event;
|
||||
req.galleryToken = decoded;
|
||||
req.token = token;
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
logger.error('Gallery auth middleware error:', error);
|
||||
res.status(401).json({ error: 'Authentication failed' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Photo access authentication
|
||||
* Validates both admin and gallery tokens for photo access
|
||||
*/
|
||||
async function photoAuth(req, res, next) {
|
||||
try {
|
||||
const slug = req.params?.slug || req.requestedSlug;
|
||||
const token = getAdminTokenFromRequest(req) || getGalleryTokenFromRequest(req, slug);
|
||||
if (!token) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
let decoded;
|
||||
try {
|
||||
decoded = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] });
|
||||
} catch (err) {
|
||||
return res.status(401).json({ error: 'Invalid token' });
|
||||
}
|
||||
|
||||
// Check if token is revoked
|
||||
if (await isTokenRevoked(decoded)) {
|
||||
return res.status(401).json({ error: 'Token has been revoked', code: 'TOKEN_REVOKED' });
|
||||
}
|
||||
|
||||
// Allow both admin and gallery tokens
|
||||
if (decoded.type === 'admin') {
|
||||
const admin = await db('admin_users')
|
||||
.where({ id: decoded.id, is_active: formatBoolean(true) })
|
||||
.first();
|
||||
|
||||
if (!admin) {
|
||||
return res.status(401).json({ error: 'Invalid token' });
|
||||
}
|
||||
|
||||
req.auth = { type: 'admin', user: admin };
|
||||
} else if (decoded.type === 'gallery') {
|
||||
const event = await db('events')
|
||||
.where({
|
||||
id: decoded.eventId,
|
||||
is_active: true,
|
||||
is_archived: false
|
||||
})
|
||||
.first();
|
||||
|
||||
if (!event) {
|
||||
return res.status(404).json({ error: 'Gallery not found' });
|
||||
}
|
||||
|
||||
// For gallery tokens, ensure they can only access their event's photos
|
||||
req.auth = { type: 'gallery', event: event };
|
||||
} else {
|
||||
return res.status(403).json({ error: 'Invalid token type' });
|
||||
}
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
logger.error('Photo auth middleware error:', error);
|
||||
res.status(401).json({ error: 'Authentication failed' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify gallery access for specific operations
|
||||
*/
|
||||
async function verifyGalleryAccess(req, res, next) {
|
||||
try {
|
||||
if (!req.auth) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
const { eventId } = req.params;
|
||||
|
||||
// Admins can access any gallery
|
||||
if (req.auth.type === 'admin') {
|
||||
return next();
|
||||
}
|
||||
|
||||
// Gallery tokens can only access their own event
|
||||
if (req.auth.type === 'gallery') {
|
||||
if (req.auth.event.id !== parseInt(eventId)) {
|
||||
return res.status(403).json({ error: 'Access denied' });
|
||||
}
|
||||
return next();
|
||||
}
|
||||
|
||||
res.status(403).json({ error: 'Access denied' });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Access verification failed' });
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
adminAuth,
|
||||
galleryAuth,
|
||||
photoAuth,
|
||||
verifyGalleryAccess
|
||||
adminAuth
|
||||
};
|
||||
|
||||
@@ -92,6 +92,16 @@ const handleKnownErrors = (err) => {
|
||||
return new ValidationError('Unexpected file field');
|
||||
}
|
||||
|
||||
// CVE-2026-82333: multer 2.3.0's fieldArrayIndexLimit rejects multipart
|
||||
// field names with an oversized bracket array index (e.g. `a[99999999]`)
|
||||
// before the DoS-prone field parser runs. Without this mapping the
|
||||
// resulting MulterError has no .statusCode/.status and falls through to
|
||||
// a 500 here, so map it to a proper 400 like the other multer limits.
|
||||
if (err.code === 'LIMIT_FIELD_ARRAY_INDEX') {
|
||||
const { ValidationError } = require('../utils/errors');
|
||||
return new ValidationError('Field name array index too large');
|
||||
}
|
||||
|
||||
return err;
|
||||
};
|
||||
|
||||
|
||||
@@ -2,18 +2,134 @@ const jwt = require('jsonwebtoken');
|
||||
const { db, withRetry } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { getGalleryTokenFromRequest } = require('../utils/tokenUtils');
|
||||
const { userHasAllPermissions } = require('./permissions');
|
||||
const { isTokenRevoked } = require('../utils/tokenRevocation');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
// Check if the request carries a valid admin preview token (Feature 3)
|
||||
function isAdminPreview(req) {
|
||||
const previewToken = req.query?.preview;
|
||||
if (!previewToken) return false;
|
||||
// Admin preview of an unpublished gallery.
|
||||
//
|
||||
// Two transports (#1386):
|
||||
//
|
||||
// admin_preview=1 — an INTENT flag, authenticated by the admin's existing
|
||||
// HttpOnly admin_token cookie (or an Authorization
|
||||
// bearer). This is the one the frontend uses. The cookie
|
||||
// rides along on same-origin requests automatically,
|
||||
// including the native fetch() that AuthenticatedImage
|
||||
// uses, so media works too — and no credential ever
|
||||
// appears in a URL.
|
||||
//
|
||||
// preview=<jwt> — the original transport, kept so existing hand-built
|
||||
// links keep working. It puts an admin JWT in the query
|
||||
// string, which reaches nginx access logs, browser
|
||||
// history and Referer headers, so nothing emits it any
|
||||
// more.
|
||||
function previewTokenFrom(req) {
|
||||
if (req.query?.admin_preview === '1') {
|
||||
const header = req.headers?.authorization;
|
||||
const bearer = header && header.startsWith('Bearer ') ? header.substring(7) : null;
|
||||
const candidate = req.cookies?.admin_token || bearer;
|
||||
if (candidate) return candidate;
|
||||
}
|
||||
return req.query?.preview || null;
|
||||
}
|
||||
|
||||
function decodeAdminToken(token) {
|
||||
if (!token) return null;
|
||||
try {
|
||||
const decoded = jwt.verify(previewToken, process.env.JWT_SECRET, { issuer: 'picpeak-auth' });
|
||||
return decoded.type === 'admin';
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET, {
|
||||
algorithms: ['HS256'], issuer: 'picpeak-auth',
|
||||
});
|
||||
return decoded.type === 'admin' ? decoded : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Signature-only predicate. It proves the caller holds SOME valid admin token
|
||||
* and nothing else — not that the account still exists, not that the token is
|
||||
* unrevoked, and not that this admin may see this event.
|
||||
*
|
||||
* Its only legitimate use is shaping the event lookup, which has to decide
|
||||
* whether to include drafts BEFORE there is an event to authorize against.
|
||||
* Every such lookup must be followed by assertDraftPreviewAllowed (#1411).
|
||||
*/
|
||||
function previewClaimed(req) {
|
||||
return decodeAdminToken(previewTokenFrom(req)) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full authorization for previewing a specific event (#1411).
|
||||
*
|
||||
* The signature check above used to be the whole story, so any valid admin
|
||||
* token previewed any draft — including one created by a different admin, and
|
||||
* including an account whose role grants neither events.view nor photos.view.
|
||||
* `main` closes this via access.authorize; this is the same rule applied where
|
||||
* this branch keeps its checks.
|
||||
*/
|
||||
async function verifyAdminPreview(req, event) {
|
||||
const decoded = decodeAdminToken(previewTokenFrom(req));
|
||||
if (!decoded || !event) return false;
|
||||
|
||||
// A signed-out or rotated session must stop previewing, same as it stops
|
||||
// reaching every other admin surface. This is the check isAdminPreview
|
||||
// carried for GHSA-q7f7-gjx8-mf6h — a revoked admin session must not keep
|
||||
// granting preview access through a bookmarked or shared link — kept here,
|
||||
// at the point where preview is actually authorized rather than where the
|
||||
// event lookup is merely shaped.
|
||||
try {
|
||||
if (await isTokenRevoked(decoded)) return false;
|
||||
} catch (error) {
|
||||
// Fail closed: a transient DB fault must not become a free preview.
|
||||
logger.warn('Admin preview revocation check failed', { error: error.message });
|
||||
return false;
|
||||
}
|
||||
|
||||
let admin;
|
||||
try {
|
||||
admin = await db('admin_users')
|
||||
.leftJoin('roles', 'roles.id', 'admin_users.role_id')
|
||||
.where({ 'admin_users.id': decoded.id, 'admin_users.is_active': formatBoolean(true) })
|
||||
.select('admin_users.id', 'roles.name as role_name')
|
||||
.first();
|
||||
} catch (error) {
|
||||
// Same posture as adminAuth's join fallback: an install whose roles table
|
||||
// predates the schema still has admins, but it has no role to check, so
|
||||
// ownership below is the only gate that applies.
|
||||
logger.debug('Admin preview role lookup failed', { error: error.message });
|
||||
admin = await db('admin_users')
|
||||
.where({ id: decoded.id, is_active: formatBoolean(true) })
|
||||
.select('id').first();
|
||||
if (admin) admin.role_name = null;
|
||||
}
|
||||
if (!admin) return false;
|
||||
|
||||
// Ownership: super_admin sees everything, everyone else sees ownerless
|
||||
// (legacy/system) events plus their own — the rule requireEventOwnership
|
||||
// and scopeEventsQuery already enforce elsewhere.
|
||||
const owns = admin.role_name === 'super_admin'
|
||||
|| !event.created_by
|
||||
|| Number(event.created_by) === Number(admin.id);
|
||||
if (!owns) return false;
|
||||
|
||||
try {
|
||||
return await userHasAllPermissions(admin.id, ['events.view', 'photos.view']);
|
||||
} catch (error) {
|
||||
logger.warn('Admin preview permission check failed', { error: error.message });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gate a loaded event behind the preview rules. Published events pass through
|
||||
* untouched; a draft is visible only to an authorized admin preview. Returns
|
||||
* false when the caller must be told the gallery does not exist.
|
||||
*/
|
||||
async function assertDraftPreviewAllowed(req, event) {
|
||||
if (!event) return true;
|
||||
const isDraft = event.is_draft === true || event.is_draft === 1 || event.is_draft === '1';
|
||||
if (!isDraft) return true;
|
||||
return verifyAdminPreview(req, event);
|
||||
}
|
||||
|
||||
// Middleware to verify gallery access
|
||||
@@ -28,7 +144,7 @@ async function verifyGalleryAccess(req, res, next) {
|
||||
return res.status(401).json({ error: 'No token provided' });
|
||||
}
|
||||
|
||||
const adminPreview = isAdminPreview(req);
|
||||
const adminPreview = previewClaimed(req);
|
||||
event = await withRetry(async () => {
|
||||
const q = db('events')
|
||||
.where({
|
||||
@@ -46,6 +162,12 @@ async function verifyGalleryAccess(req, res, next) {
|
||||
return res.status(404).json({ error: 'Gallery not found or expired' });
|
||||
}
|
||||
|
||||
// The lookup above included drafts on a signature-only check. Authorize
|
||||
// the draft now that there is an event to authorize against (#1411).
|
||||
if (!await assertDraftPreviewAllowed(req, event)) {
|
||||
return res.status(404).json({ error: 'Gallery not found or expired' });
|
||||
}
|
||||
|
||||
const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0');
|
||||
if (!requiresPassword) {
|
||||
req.event = event;
|
||||
@@ -89,10 +211,18 @@ async function verifyGalleryAccess(req, res, next) {
|
||||
return res.status(403).json({ error: 'Invalid token type for gallery access' });
|
||||
}
|
||||
|
||||
// Gallery logout writes to the revocation store (see routes/auth.js),
|
||||
// but nothing on this path ever read it back (GHSA-q7f7-gjx8-mf6h) — a
|
||||
// logged-out gallery JWT kept working until natural expiry.
|
||||
if (await isTokenRevoked(decoded)) {
|
||||
logger.warn('[verifyGalleryAccess] Revoked token used', { eventId: decoded.eventId });
|
||||
return res.status(401).json({ error: 'Token has been revoked', code: 'TOKEN_REVOKED' });
|
||||
}
|
||||
|
||||
// If we have a slug in the URL params or from pre-middleware, verify it matches
|
||||
if (requestedSlug) {
|
||||
// Verify by slug and ensure it matches the token's event
|
||||
const adminPreviewToken = isAdminPreview(req);
|
||||
const adminPreviewToken = previewClaimed(req);
|
||||
event = await withRetry(async () => {
|
||||
const q = db('events')
|
||||
.where({
|
||||
@@ -112,7 +242,7 @@ async function verifyGalleryAccess(req, res, next) {
|
||||
}
|
||||
} else {
|
||||
// Fallback to using eventId from token
|
||||
const adminPreviewFallback = isAdminPreview(req);
|
||||
const adminPreviewFallback = previewClaimed(req);
|
||||
event = await withRetry(async () => {
|
||||
const q = db('events')
|
||||
.where({
|
||||
@@ -132,6 +262,12 @@ async function verifyGalleryAccess(req, res, next) {
|
||||
return res.status(404).json({ error: 'Gallery not found or expired' });
|
||||
}
|
||||
|
||||
// Same gate as the public branch above (#1411): the draft was included in
|
||||
// the lookup on a signature-only check and has to be authorized here.
|
||||
if (!await assertDraftPreviewAllowed(req, event)) {
|
||||
return res.status(404).json({ error: 'Gallery not found or expired' });
|
||||
}
|
||||
|
||||
// Customer-minted gallery JWTs (#354): when the customer obtained
|
||||
// this token via /api/customer/events/:slug/access-token, the
|
||||
// payload carries `via:'customer'` and `customerId`. The admin
|
||||
@@ -202,5 +338,7 @@ function denySlideshowToken(req, res, next) {
|
||||
module.exports = {
|
||||
verifyGalleryAccess,
|
||||
denySlideshowToken,
|
||||
isAdminPreview
|
||||
previewClaimed,
|
||||
verifyAdminPreview,
|
||||
assertDraftPreviewAllowed
|
||||
};
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
const bcrypt = require('bcrypt');
|
||||
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) {
|
||||
try {
|
||||
// Extract event slug from the path
|
||||
let eventSlug;
|
||||
|
||||
// For thumbnails, we need to parse the filename to get the event info
|
||||
if (req.path.startsWith('/thumb_')) {
|
||||
// For now, we'll rely on JWT token for thumbnail access
|
||||
eventSlug = null;
|
||||
} else {
|
||||
// For regular photos, the slug is the first part of the path
|
||||
eventSlug = req.path.split('/')[1];
|
||||
}
|
||||
|
||||
// First check for JWT token (from gallery access)
|
||||
const tokenFromRequest = getGalleryTokenFromRequest(req, eventSlug);
|
||||
if (tokenFromRequest) {
|
||||
const token = tokenFromRequest;
|
||||
try {
|
||||
// Try to verify with issuer first, fallback to no issuer for backward compatibility
|
||||
let decoded;
|
||||
try {
|
||||
decoded = jwt.verify(token, process.env.JWT_SECRET, {
|
||||
algorithms: ['HS256'],
|
||||
issuer: 'picpeak-auth'
|
||||
});
|
||||
} catch (issuerError) {
|
||||
// If verification fails with issuer, try without issuer (backward compatibility)
|
||||
if (issuerError.name === 'JsonWebTokenError' && issuerError.message.includes('jwt issuer invalid')) {
|
||||
decoded = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] });
|
||||
} else {
|
||||
throw issuerError;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if it's a gallery token
|
||||
if (decoded.type === 'gallery') {
|
||||
// For thumbnails, we need to verify the token is for a valid event
|
||||
if (!eventSlug) {
|
||||
// Resolve the token's event (by id, or legacy slug fallback)...
|
||||
let event = null;
|
||||
if (decoded.eventId) {
|
||||
event = await db('events')
|
||||
.where({ id: decoded.eventId, is_active: formatBoolean(true) })
|
||||
.first();
|
||||
}
|
||||
if (!event && decoded.eventSlug) {
|
||||
event = await db('events')
|
||||
.where({ slug: decoded.eventSlug, is_active: formatBoolean(true) })
|
||||
.first();
|
||||
}
|
||||
// ...then confirm the REQUESTED thumbnail actually belongs to
|
||||
// that event. Thumbnails are stored flat (thumbnails/thumb_<name>)
|
||||
// with deterministic, enumerable filenames derived from the
|
||||
// public event name + a sequential counter. Without this
|
||||
// ownership check any holder of a gallery token for any event
|
||||
// could enumerate and fetch another (password-protected) event's
|
||||
// entire thumbnail set, defeating the gallery password. A
|
||||
// traversal or foreign filename simply fails to match → denied.
|
||||
if (event) {
|
||||
const requestedKey = `thumbnails${req.path}`;
|
||||
const ownsThumbnail = await db('photos')
|
||||
.where({ event_id: event.id, thumbnail_path: requestedKey })
|
||||
.first();
|
||||
if (ownsThumbnail) {
|
||||
req.event = event;
|
||||
return next();
|
||||
}
|
||||
}
|
||||
}
|
||||
// For regular photos, check if token matches the event
|
||||
else if (decoded.eventSlug === eventSlug) {
|
||||
const event = await db('events')
|
||||
.where({ slug: eventSlug, is_active: formatBoolean(true) })
|
||||
.first();
|
||||
if (event) {
|
||||
req.event = event;
|
||||
return next();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if it's an admin token (admins can view all photos)
|
||||
if (decoded.type === 'admin') {
|
||||
// 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) {
|
||||
// Token invalid, fall through to password check
|
||||
logger.warn('JWT verification failed in photoAuth', { error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
// Check for password header (legacy support)
|
||||
const password = req.headers['x-gallery-password'];
|
||||
|
||||
// If no eventSlug (thumbnails), and we don't have valid auth yet, deny access
|
||||
if (!eventSlug && !password && !tokenFromRequest) {
|
||||
return res.status(401).json({ error: 'Authentication required for thumbnails' });
|
||||
}
|
||||
|
||||
const event = await db('events').where({ slug: eventSlug, is_active: formatBoolean(true) }).first();
|
||||
if (!event) {
|
||||
return res.status(404).json({ error: 'Gallery not found' });
|
||||
}
|
||||
|
||||
const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0');
|
||||
|
||||
if (!requiresPassword) {
|
||||
req.event = event;
|
||||
return next();
|
||||
}
|
||||
|
||||
if (!password && !tokenFromRequest) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
if (password) {
|
||||
const validPassword = await bcrypt.compare(password, event.password_hash);
|
||||
if (!validPassword) {
|
||||
await db('access_logs').insert({
|
||||
event_id: event.id,
|
||||
ip_address: req.ip,
|
||||
user_agent: req.headers['user-agent'],
|
||||
action: 'login_fail'
|
||||
});
|
||||
return res.status(401).json({ error: 'Invalid password' });
|
||||
}
|
||||
} else {
|
||||
// No valid authentication
|
||||
return res.status(401).json({ error: 'Invalid authentication' });
|
||||
}
|
||||
|
||||
req.event = event;
|
||||
next();
|
||||
} catch (error) {
|
||||
logger.error('Photo auth error', { error: error.message, stack: error.stack });
|
||||
res.status(500).json({ error: 'Authentication error' });
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = photoAuth;
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
const express = require('express');
|
||||
const { body, validationResult } = require('express-validator');
|
||||
const { safeValidationErrors } = require('../utils/routeHelpers');
|
||||
const { db, logActivity } = require('../database/db');
|
||||
const { adminAuth } = require('./../middleware/auth');
|
||||
const { requirePermission } = require('./../middleware/permissions');
|
||||
@@ -65,7 +66,7 @@ router.post(
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
return res.status(400).json({ errors: safeValidationErrors(errors) });
|
||||
}
|
||||
const { name, scopes, expires_at } = req.body;
|
||||
const { plaintext, hashed, preview } = generateApiToken();
|
||||
|
||||
@@ -10,6 +10,7 @@ const archiver = require('archiver');
|
||||
const StreamZip = require('node-stream-zip');
|
||||
const { requireEventOwnership } = require('../middleware/ownership');
|
||||
const { assertZipEntriesWithin } = require('../utils/safePath');
|
||||
const { sanitizeForZipEntry } = require('../utils/filenameSanitizer');
|
||||
const logger = require('../utils/logger');
|
||||
const { getPagination } = require('../utils/routeHelpers');
|
||||
const router = express.Router();
|
||||
@@ -204,15 +205,111 @@ router.post('/:id/restore', adminAuth, requirePermission('archives.restore'), re
|
||||
// manifest the archive process writes. Older archives have no manifest;
|
||||
// we fall back to filename for those.
|
||||
const manifestByFilename = new Map();
|
||||
// Aliases that more than one manifest row claims — see the loop below.
|
||||
const ambiguousAliases = new Set();
|
||||
try {
|
||||
const manifestRaw = await fs.readFile(
|
||||
path.join(eventDir, 'photos_manifest.json'), 'utf8',
|
||||
);
|
||||
const parsed = JSON.parse(manifestRaw);
|
||||
if (Array.isArray(parsed)) {
|
||||
for (const m of parsed) {
|
||||
if (m && m.filename) manifestByFilename.set(m.filename, m);
|
||||
// Two passes, and the order is the point. Canonical photos.filename
|
||||
// keys are claimed first and never yielded afterwards; aliases only
|
||||
// fill names no canonical row wanted. Interleaving them made the
|
||||
// result depend on manifest iteration order — the query has no
|
||||
// ORDER BY — and could delete a canonical key because some OTHER
|
||||
// row's original_filename happened to collide with it.
|
||||
const rows = parsed.filter((m) => m && m.filename);
|
||||
|
||||
// photos.filename is not unique within an event: s3AutoImporter
|
||||
// takes path.basename(entry.key) and dedupes by path, so two
|
||||
// imported files in different subfolders both land as `IMG_1234.jpg`
|
||||
// with different `path` values. At restore both ZIP entries reduce
|
||||
// to the same basename, so whichever row won the key would hand the
|
||||
// other photo someone else's category. Contested names are dropped
|
||||
// rather than guessed.
|
||||
const contestedFilenames = new Set();
|
||||
for (const m of rows) {
|
||||
const held = manifestByFilename.get(m.filename);
|
||||
if (held && held !== m) {
|
||||
contestedFilenames.add(m.filename);
|
||||
continue;
|
||||
}
|
||||
manifestByFilename.set(m.filename, m);
|
||||
}
|
||||
for (const name of contestedFilenames) manifestByFilename.delete(name);
|
||||
if (contestedFilenames.size) {
|
||||
logger.warn(
|
||||
`Photos manifest: ${contestedFilenames.size} filename(s) claimed by more than one photo; `
|
||||
+ 'those fall back to the directory for their category.'
|
||||
);
|
||||
}
|
||||
|
||||
// Every canonical name, contested ones included — an alias must not
|
||||
// claim a name that a canonical row wanted and lost, either.
|
||||
const canonicalNames = new Set(rows.map((m) => m.filename));
|
||||
|
||||
for (const m of rows) {
|
||||
// Also index by original_filename. When
|
||||
// general_use_original_filenames_for_downloads was on at archive
|
||||
// time, archiveService names each ZIP entry after the ORIGINAL
|
||||
// filename, while the manifest stays keyed by the internal
|
||||
// photos.filename — so a lookup by the extracted basename misses
|
||||
// every entry and the restore silently loses categories on exactly
|
||||
// those archives. Never overwrite a real filename key: that one is
|
||||
// authoritative if both happen to collide.
|
||||
// Index the name as the ZIP would have EMITTED it, not the raw
|
||||
// column: archiveService runs original names through
|
||||
// sanitizeForZipEntry() before writing the entry, so an original
|
||||
// with a slash or a control byte lands under a different name than
|
||||
// the manifest records. Index both, so either spelling resolves.
|
||||
//
|
||||
// Still not total: uniquifyZipNames() appends `_1` when two photos
|
||||
// in one event share an original name, and that suffix cannot be
|
||||
// reconstructed from the manifest. Those few fall through to the
|
||||
// directory, exactly as they did before this fix — no worse, just
|
||||
// not better. Closing that needs the emitted name recorded at
|
||||
// archive time, which is a writer change and a new archive format.
|
||||
for (const alias of [m.original_filename, sanitizeForZipEntry(m.original_filename)]) {
|
||||
if (!alias) continue;
|
||||
// An alias colliding with someone else's canonical name is
|
||||
// genuinely undecidable, so it is dropped rather than resolved
|
||||
// either way. Which photo the ZIP emitted under that name
|
||||
// depends on whether original-filename archiving was on at
|
||||
// archive time, and the manifest does not record that: with it
|
||||
// ON the entry is the ALIAS owner's file, with it OFF it is the
|
||||
// canonical owner's. Preferring either one silently mislabels
|
||||
// the other half of the time.
|
||||
//
|
||||
// What the two-pass split buys is that this is now decided the
|
||||
// same way every run — the archive query has no ORDER BY, so
|
||||
// interleaving the passes previously made it a coin flip
|
||||
// between dropping the name and overwriting it.
|
||||
if (canonicalNames.has(alias)) {
|
||||
if (manifestByFilename.get(alias) !== m) ambiguousAliases.add(alias);
|
||||
continue;
|
||||
}
|
||||
if (manifestByFilename.has(alias)) {
|
||||
// Two rows want the same alias — e.g. `individual/IMG.jpg` and
|
||||
// `collages/IMG.jpg`, which archiveService treats as distinct
|
||||
// paths and does not suffix, but which collapse to one basename
|
||||
// here. Whichever won would give the other photo someone else's
|
||||
// category. Drop the alias so both fall through to the
|
||||
// directory instead: an unresolved category is recoverable, a
|
||||
// confidently wrong one is not.
|
||||
if (manifestByFilename.get(alias) !== m) ambiguousAliases.add(alias);
|
||||
continue;
|
||||
}
|
||||
manifestByFilename.set(alias, m);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const alias of ambiguousAliases) manifestByFilename.delete(alias);
|
||||
if (ambiguousAliases.size) {
|
||||
logger.warn(
|
||||
`Photos manifest: ${ambiguousAliases.size} original-filename alias(es) claimed by more than one `
|
||||
+ 'photo; those fall back to the directory for their category.'
|
||||
);
|
||||
}
|
||||
logger.info(`Loaded photos manifest: ${manifestByFilename.size} entries`);
|
||||
} catch (e) {
|
||||
@@ -226,9 +323,84 @@ router.post('/:id/restore', adminAuth, requirePermission('archives.restore'), re
|
||||
// Get list of extracted files to update database
|
||||
const extractedPhotos = [];
|
||||
|
||||
// First, collect all category information from the ZIP structure
|
||||
// Category name -> id, resolved once per name for the whole restore.
|
||||
const categoriesMap = new Map();
|
||||
|
||||
// Find-or-create the category by name, among the ones this event can see.
|
||||
const resolveCategoryId = async (categoryName) => {
|
||||
if (!categoryName) return null;
|
||||
if (categoriesMap.has(categoryName)) return categoriesMap.get(categoryName);
|
||||
|
||||
// Globals count as existing. A photo filed under the seeded "Ceremony"
|
||||
// has event_id NULL on its category row, so an event-only lookup misses
|
||||
// it and creates a second "Ceremony" — and since is_global defaults to
|
||||
// TRUE, that duplicate then shows up in every other event's category
|
||||
// list. Same visibility rule the photo routes use: own rows or global.
|
||||
// Two queries, not one with an OR: an event-scoped category and a
|
||||
// global one may share a name, and a single .first() would return
|
||||
// whichever the engine felt like — silently reassigning a photo to the
|
||||
// global row and losing event-local settings like allow_downloads.
|
||||
// The event's own row is the more specific answer, so it wins.
|
||||
//
|
||||
// The global arm requires event_id IS NULL, not just is_global. The
|
||||
// bug fixed here left legacy rows behind on upgraded instances —
|
||||
// event-owned AND is_global true, because the column defaults true —
|
||||
// and matching on the flag alone would let one event's leftover row be
|
||||
// adopted by another event's restore, tying photos to a category that
|
||||
// vanishes with someone else's gallery.
|
||||
// Two event-scoped categories CAN share a display name when their
|
||||
// slugs differ, and .first() would then pick one arbitrarily — both
|
||||
// manifest names collapse onto a single id and half the photos
|
||||
// inherit the wrong per-category settings (allow_downloads above all).
|
||||
// Resolving that properly needs a stable category identifier in the
|
||||
// manifest, which is a writer change and an archive-format bump, and
|
||||
// could not help any archive already written. So: surface it instead
|
||||
// of fixing it blind. If this never fires in real logs, the format
|
||||
// change is not worth making; if it does, this is the evidence for it.
|
||||
const ownRows = await db('photo_categories')
|
||||
.where({ event_id: archive.id, name: categoryName })
|
||||
.select('id');
|
||||
if (ownRows.length > 1) {
|
||||
logger.warn(
|
||||
`Photos manifest: category name "${categoryName}" matches ${ownRows.length} rows in event `
|
||||
+ `${archive.id}; picking the lowest id. Photos from the other row(s) will inherit its settings.`
|
||||
);
|
||||
}
|
||||
|
||||
const existingCategory =
|
||||
// Lowest id, not engine order — an arbitrary-but-stable choice beats
|
||||
// a nondeterministic one, so a re-run lands the same way.
|
||||
(ownRows.length
|
||||
? await db('photo_categories')
|
||||
.where('id', Math.min(...ownRows.map((r) => r.id)))
|
||||
.first()
|
||||
: null)
|
||||
|| await db('photo_categories')
|
||||
.where('name', categoryName)
|
||||
.whereNull('event_id')
|
||||
.where('is_global', formatBoolean(true))
|
||||
.first();
|
||||
|
||||
if (existingCategory) {
|
||||
categoriesMap.set(categoryName, existingCategory.id);
|
||||
} else {
|
||||
const insertResult = await db('photo_categories').insert({
|
||||
event_id: archive.id,
|
||||
name: categoryName,
|
||||
slug: slugify(categoryName),
|
||||
// Explicit: the column defaults to true, and a restore inventing a
|
||||
// GLOBAL category would leak this event's naming into every other
|
||||
// gallery. Anything created here belongs to this event alone.
|
||||
is_global: formatBoolean(false),
|
||||
created_at: new Date()
|
||||
}).returning('id');
|
||||
|
||||
categoriesMap.set(categoryName, insertResult[0]?.id || insertResult[0]);
|
||||
}
|
||||
|
||||
return categoriesMap.get(categoryName);
|
||||
};
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory && entry.name.match(/\.(jpg|jpeg|png|gif|webp)$/i)) {
|
||||
const filename = path.basename(entry.name);
|
||||
@@ -239,48 +411,47 @@ router.post('/:id/restore', adminAuth, requirePermission('archives.restore'), re
|
||||
// Check if file was extracted successfully
|
||||
const stats = await fs.stat(actualFilePath);
|
||||
|
||||
// Determine category from directory structure
|
||||
let categoryId = null;
|
||||
if (dirPath && dirPath !== '.') {
|
||||
// Get the first level directory as category
|
||||
const categoryName = dirPath.split(path.sep)[0];
|
||||
|
||||
if (!categoriesMap.has(categoryName)) {
|
||||
// Check if this category exists in the database
|
||||
const existingCategory = await db('photo_categories')
|
||||
.where('event_id', archive.id)
|
||||
.where('name', categoryName)
|
||||
.first();
|
||||
|
||||
if (existingCategory) {
|
||||
categoriesMap.set(categoryName, existingCategory.id);
|
||||
} else {
|
||||
// Create the category if it doesn't exist
|
||||
const insertResult = await db('photo_categories').insert({
|
||||
event_id: archive.id,
|
||||
name: categoryName,
|
||||
slug: slugify(categoryName),
|
||||
created_at: new Date()
|
||||
}).returning('id');
|
||||
|
||||
const newCategoryId = insertResult[0]?.id || insertResult[0];
|
||||
categoriesMap.set(categoryName, newCategoryId);
|
||||
}
|
||||
}
|
||||
|
||||
categoryId = categoriesMap.get(categoryName);
|
||||
}
|
||||
|
||||
const manifestEntry = manifestByFilename.get(filename);
|
||||
|
||||
// The manifest is the only faithful source for the category, and
|
||||
// it is authoritative INCLUDING when it says "none". A manifest
|
||||
// entry with a null category_name means the photo was genuinely
|
||||
// uncategorized, so falling through to the directory would
|
||||
// contradict the very record being restored from.
|
||||
//
|
||||
// That matters because the directory is not a category. Archive
|
||||
// entry names are the storage key minus `events/active/{slug}`,
|
||||
// and that layout is `individual/{filename}` / `collages/…` —
|
||||
// categories have never been directories there. Reading the first
|
||||
// path segment on a real archive therefore invents categories
|
||||
// literally named "individual" and "collages".
|
||||
//
|
||||
// So the fallback is confined to photos with NO manifest entry at
|
||||
// all: archives written before the manifest existed, where the
|
||||
// directory is the only signal left and inventing those two names
|
||||
// is still better than losing every category.
|
||||
// Check if photo already exists in database
|
||||
const existingPhoto = await db('photos')
|
||||
.where('event_id', archive.id)
|
||||
.where('filename', filename)
|
||||
.first();
|
||||
|
||||
|
||||
if (!existingPhoto) {
|
||||
// Resolved HERE, not above: resolveCategoryId find-or-CREATES,
|
||||
// and archiveEvent retains photo rows. Resolving before this
|
||||
// check meant restoring an archive whose rows still exist
|
||||
// created a category from the stale manifest name that nothing
|
||||
// then used — so renaming a category while its event was
|
||||
// archived left the old name behind as an empty duplicate.
|
||||
let categoryId = null;
|
||||
if (manifestEntry) {
|
||||
categoryId = await resolveCategoryId(manifestEntry.category_name);
|
||||
} else if (dirPath && dirPath !== '.') {
|
||||
categoryId = await resolveCategoryId(dirPath.split(path.sep)[0]);
|
||||
}
|
||||
|
||||
// Store relative path from storage root
|
||||
const relativePath = path.relative(storagePath, actualFilePath);
|
||||
const manifestEntry = manifestByFilename.get(filename);
|
||||
extractedPhotos.push({
|
||||
event_id: archive.id,
|
||||
filename: filename,
|
||||
@@ -293,7 +464,13 @@ router.post('/:id/restore', adminAuth, requirePermission('archives.restore'), re
|
||||
type: path.extname(filename).substring(1).toLowerCase(),
|
||||
size_bytes: stats.size,
|
||||
category_id: categoryId,
|
||||
uploaded_at: new Date()
|
||||
// .toISOString(), not a Date: inside jest the sqlite3 binding's
|
||||
// type dispatch misses sandbox-created Dates and stores the
|
||||
// literal string "[object Object]", so every restored photo
|
||||
// gets a garbage timestamp that any test reading it would
|
||||
// believe. Production stores Dates as ms-numbers and is
|
||||
// unaffected — which is exactly why this survives unnoticed.
|
||||
uploaded_at: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
} catch (statError) {
|
||||
|
||||
@@ -254,6 +254,11 @@ router.post('/mfa/setup', adminAuth, handleAsync(async (req, res) => {
|
||||
|
||||
// Complete enrollment: verify a code against the provisional secret, enable
|
||||
// MFA, and return one-time recovery codes (shown exactly once).
|
||||
//
|
||||
// No replay tracking here: this confirms an already-authenticated session
|
||||
// still holds the authenticator (no new session is granted), and starting
|
||||
// the last-used-step counter here would reject the very next login if it
|
||||
// lands in the same 30s TOTP step as this call.
|
||||
router.post('/mfa/enable', [
|
||||
adminAuth,
|
||||
body('code').notEmpty().withMessage('Verification code is required')
|
||||
@@ -304,7 +309,17 @@ router.post('/mfa/disable', [
|
||||
throw new ValidationError('Two-factor authentication is not enabled');
|
||||
}
|
||||
|
||||
const totpOk = mfaService.verifyTotpEncrypted(req.body.code, admin.two_factor_secret);
|
||||
// Persist the matched step atomically right here (see mfaService.persistTotpStep):
|
||||
// two concurrent requests carrying the same captured code can't both read the
|
||||
// same last-used step and both win — only the first writer's UPDATE affects a
|
||||
// row, so a losing concurrent request is correctly treated as invalid below.
|
||||
const totpStep = mfaService.verifyTotpEncryptedStep(
|
||||
req.body.code, admin.two_factor_secret, admin.two_factor_last_used_step
|
||||
);
|
||||
let totpOk = false;
|
||||
if (totpStep !== null) {
|
||||
totpOk = await mfaService.persistTotpStep(db, admin.id, totpStep, { updated_at: new Date() });
|
||||
}
|
||||
let recoveryOk = false;
|
||||
if (!totpOk) {
|
||||
const stored = mfaService.parseRecoveryCodes(admin.two_factor_recovery_codes);
|
||||
@@ -319,6 +334,7 @@ router.post('/mfa/disable', [
|
||||
two_factor_secret: null,
|
||||
two_factor_recovery_codes: null,
|
||||
two_factor_enrolled_at: null,
|
||||
two_factor_last_used_step: null,
|
||||
updated_at: new Date()
|
||||
});
|
||||
|
||||
@@ -343,7 +359,16 @@ router.post('/mfa/recovery-codes', [
|
||||
if (!isMfaEnabled(admin)) {
|
||||
throw new ValidationError('Two-factor authentication is not enabled');
|
||||
}
|
||||
if (!mfaService.verifyTotpEncrypted(req.body.code, admin.two_factor_secret)) {
|
||||
// Persist the matched step atomically right here (see mfaService.persistTotpStep):
|
||||
// two concurrent requests carrying the same captured code can't both read the
|
||||
// same last-used step and both win — only the first writer's UPDATE affects a
|
||||
// row, so a losing concurrent request is correctly treated as invalid below.
|
||||
const totpStep = mfaService.verifyTotpEncryptedStep(
|
||||
req.body.code, admin.two_factor_secret, admin.two_factor_last_used_step
|
||||
);
|
||||
const totpOk = totpStep !== null
|
||||
&& await mfaService.persistTotpStep(db, admin.id, totpStep, { updated_at: new Date() });
|
||||
if (!totpOk) {
|
||||
throw new ValidationError('Invalid verification code');
|
||||
}
|
||||
|
||||
|
||||
@@ -191,7 +191,11 @@ const picpeakUpload = multer({
|
||||
destination: (req, file, cb) => cb(null, os.tmpdir()),
|
||||
filename: (req, file, cb) => cb(null, `picpeak-upload-${Date.now()}-${crypto.randomBytes(6).toString('hex')}.picpeak`),
|
||||
}),
|
||||
limits: { fileSize: 5 * 1024 * 1024 * 1024 }, // 5 GB — .picpeak with photos can be large
|
||||
// CVE-2026-82333: this route only ever consumes a single unnamed file
|
||||
// field (`backup`) — no legitimate bracket-indexed field name (e.g.
|
||||
// `a[0]`) exists in its form. fieldArrayIndexLimit: 0 rejects any field
|
||||
// name using array-index syntax at all, closing multer's field-parser DoS.
|
||||
limits: { fileSize: 5 * 1024 * 1024 * 1024, fieldArrayIndexLimit: 0 }, // 5 GB — .picpeak with photos can be large
|
||||
});
|
||||
|
||||
// Upload + restore a .picpeak onto THIS instance. DESTRUCTIVE: full override of
|
||||
|
||||
@@ -22,6 +22,8 @@ const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers');
|
||||
const { getStoragePath } = require('../config/storage');
|
||||
const { uploadedPdfLogoPath } = require('../utils/safePath');
|
||||
const { validateFileType, validateFileContent, ALLOWED_MEDIA_TYPES } = require('../utils/fileSecurityUtils');
|
||||
const businessProfileService = require('../services/businessProfileService');
|
||||
const { db } = require('../database/db');
|
||||
const { validateIban } = require('../utils/iban');
|
||||
@@ -94,6 +96,19 @@ const router = express.Router();
|
||||
// but accepts SVG in addition to PNG / JPEG — the PDF renderer
|
||||
// rasterises SVGs to PNG on the fly via resolveLogoFile() so the
|
||||
// admin can drop a vector logo here and have it work in print.
|
||||
//
|
||||
// GHSA-6wrv-9pr4-hhmw: this route used to take the stored extension
|
||||
// straight from `file.originalname` and only checked `file.mimetype`
|
||||
// against an allowlist — a file could declare an image MIME type
|
||||
// while carrying a `.html`/`.js` extension and arbitrary content, get
|
||||
// served same-origin from /uploads/logos with that extension, and
|
||||
// execute as script in the browser. Fixed the same way every sibling
|
||||
// upload route (adminSettings.js, adminCMS.js) already does it:
|
||||
// `validateFileType()` pairs the claimed MIME type against the
|
||||
// extension, and the extension actually written to disk is looked up
|
||||
// from the validated MIME type — never taken from client input.
|
||||
const PDF_LOGO_ALLOWED_MIME_TYPES = ['image/png', 'image/jpeg', 'image/svg+xml'];
|
||||
|
||||
const pdfLogoStorage = multer.diskStorage({
|
||||
destination: async (_req, _file, cb) => {
|
||||
const dir = path.join(getStoragePath(), 'uploads/logos');
|
||||
@@ -101,18 +116,26 @@ const pdfLogoStorage = multer.diskStorage({
|
||||
cb(null, dir);
|
||||
},
|
||||
filename: (_req, file, cb) => {
|
||||
const ext = path.extname(file.originalname) || '.png';
|
||||
// fileFilter (below) runs before this and already rejected any
|
||||
// mimetype outside PDF_LOGO_ALLOWED_MIME_TYPES, so the lookup below
|
||||
// always hits. The extension is derived from the validated MIME
|
||||
// type, never from file.originalname.
|
||||
const ext = ALLOWED_MEDIA_TYPES[file.mimetype]?.extensions[0] || '.png';
|
||||
cb(null, `pdf-logo-${Date.now()}${ext}`);
|
||||
},
|
||||
});
|
||||
|
||||
const pdfLogoUpload = multer({
|
||||
storage: pdfLogoStorage,
|
||||
limits: { fileSize: 5 * 1024 * 1024 },
|
||||
// CVE-2026-82333: single unnamed `logo` field only — no legitimate
|
||||
// array-indexed field names, so reject any bracket-index field name.
|
||||
limits: { fileSize: 5 * 1024 * 1024, fieldArrayIndexLimit: 0 },
|
||||
fileFilter: (_req, file, cb) => {
|
||||
const allowed = ['image/png', 'image/jpeg', 'image/svg+xml'];
|
||||
if (allowed.includes(file.mimetype)) cb(null, true);
|
||||
else cb(new Error('Only PNG, JPEG and SVG logos are allowed'));
|
||||
if (validateFileType(file.originalname, file.mimetype, PDF_LOGO_ALLOWED_MIME_TYPES)) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Only PNG, JPEG and SVG logos are allowed'));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -352,18 +375,27 @@ router.post(
|
||||
return res.status(400).json({ error: 'No logo file uploaded' });
|
||||
}
|
||||
|
||||
// fileFilter above only pairs the claimed MIME type against the
|
||||
// extension — it runs on the in-flight stream, before any bytes are
|
||||
// written, so it can't inspect content. Content-sniff the bytes multer
|
||||
// just wrote to disk (magic numbers) before trusting them; SVG has no
|
||||
// magic-number check (validateFileContent returns true for it), it's
|
||||
// protected by the CSP header instead. Matches the cleanup-then-reject
|
||||
// pattern createFileUploadValidator() uses for other upload routes.
|
||||
const contentIsValid = await validateFileContent(req.file.path, req.file.mimetype);
|
||||
if (!contentIsValid) {
|
||||
try { await fs.unlink(req.file.path); } catch (_) { /* ignore */ }
|
||||
return res.status(400).json({ error: 'File content does not match its declared type' });
|
||||
}
|
||||
|
||||
// Clean up the previous PDF logo on disk if it was uploaded via
|
||||
// this same endpoint (matches the pdf-logo-* prefix). We leave
|
||||
// anything else untouched — the admin may have set logo_path to
|
||||
// a path managed by a different system.
|
||||
try {
|
||||
const previous = await db('business_profile').where({ id: 1 }).first();
|
||||
const prev = previous?.logo_path;
|
||||
if (prev && typeof prev === 'string' && /pdf-logo-\d+\./.test(prev)) {
|
||||
const stripped = prev.replace(/^\/+/, '');
|
||||
const prevDisk = path.isAbsolute(prev)
|
||||
? prev
|
||||
: path.join(getStoragePath(), stripped);
|
||||
const prevDisk = uploadedPdfLogoPath(previous?.logo_path, getStoragePath());
|
||||
if (prevDisk) {
|
||||
try { await fs.unlink(prevDisk); } catch (_) { /* ignore */ }
|
||||
}
|
||||
} catch (_) { /* ignore */ }
|
||||
@@ -383,12 +415,8 @@ router.delete(
|
||||
requirePermission('settings.edit'),
|
||||
handleAsync(async (req, res) => {
|
||||
const existing = await db('business_profile').where({ id: 1 }).first();
|
||||
const prev = existing?.logo_path;
|
||||
if (prev && typeof prev === 'string' && /pdf-logo-\d+\./.test(prev)) {
|
||||
const stripped = prev.replace(/^\/+/, '');
|
||||
const prevDisk = path.isAbsolute(prev)
|
||||
? prev
|
||||
: path.join(getStoragePath(), stripped);
|
||||
const prevDisk = uploadedPdfLogoPath(existing?.logo_path, getStoragePath());
|
||||
if (prevDisk) {
|
||||
try { await fs.unlink(prevDisk); } catch (_) { /* ignore */ }
|
||||
}
|
||||
await businessProfileService.updateProfile(
|
||||
@@ -433,7 +461,19 @@ router.put(
|
||||
body('defaultLocale').optional({ values: 'falsy' }).isString().isLength({ max: 8 }),
|
||||
body('defaultQrFormat').optional({ values: 'falsy' }).isIn(['swiss', 'epc', 'none']),
|
||||
body('footerLine').optional({ values: 'falsy' }).isString().isLength({ max: 255 }),
|
||||
body('logoPath').optional({ values: 'falsy' }).isString().isLength({ max: 512 }),
|
||||
// GHSA-6wrv-9pr4-hhmw: logoPath is mass-assignable here, so it must
|
||||
// only ever be settable to a path the POST /logo upload route itself
|
||||
// produced (or '' to clear it, allowed by `values: 'falsy'` above) —
|
||||
// not an arbitrary string chaining in a file uploaded elsewhere.
|
||||
// uploadedPdfLogoPath() is the same pattern check the delete/replace
|
||||
// cleanup path already trusts to name a file this route wrote.
|
||||
body('logoPath').optional({ values: 'falsy' }).isString().isLength({ max: 512 })
|
||||
.custom((value) => {
|
||||
if (!uploadedPdfLogoPath(value, getStoragePath())) {
|
||||
throw new Error('logoPath must be a path produced by the logo upload endpoint');
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
// Bundled-fonts dropdown (migration 121). Free-text upload field
|
||||
// (pdfFontTtfPath, migration 103) was retired from the UI in
|
||||
// favour of this dropdown; the column stays in the DB so any
|
||||
|
||||
@@ -3,6 +3,7 @@ const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const multer = require('multer');
|
||||
const { body, validationResult } = require('express-validator');
|
||||
const { safeValidationErrors } = require('../utils/routeHelpers');
|
||||
const { db, logActivity } = require('../database/db');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
@@ -30,7 +31,9 @@ const pageLogoStorage = multer.diskStorage({
|
||||
|
||||
const pageLogoUpload = multer({
|
||||
storage: pageLogoStorage,
|
||||
limits: { fileSize: 5 * 1024 * 1024 },
|
||||
// CVE-2026-82333: single unnamed `logo` field only — no legitimate
|
||||
// array-indexed field names, so reject any bracket-index field name.
|
||||
limits: { fileSize: 5 * 1024 * 1024, fieldArrayIndexLimit: 0 },
|
||||
fileFilter: (_req, file, cb) => {
|
||||
const allowed = ['image/jpeg', 'image/png', 'image/gif', 'image/svg+xml'];
|
||||
if (validateFileType(file.originalname, file.mimetype, allowed)) cb(null, true);
|
||||
@@ -80,7 +83,7 @@ router.put('/pages/:slug', adminAuth, requirePermission('cms.edit'), [
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
return res.status(400).json({ errors: safeValidationErrors(errors) });
|
||||
}
|
||||
|
||||
const { slug } = req.params;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
const express = require('express');
|
||||
const { body, validationResult } = require('express-validator');
|
||||
const { safeValidationErrors } = require('../utils/routeHelpers');
|
||||
const { db, logActivity } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
@@ -51,7 +52,7 @@ router.post('/', adminAuth, requirePermission('settings.edit'), [
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
return res.status(400).json({ errors: safeValidationErrors(errors) });
|
||||
}
|
||||
|
||||
const { name, slug, is_global = true, event_id = null } = req.body;
|
||||
@@ -119,7 +120,7 @@ router.put('/:id', adminAuth, requirePermission('settings.edit'), [
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
return res.status(400).json({ errors: safeValidationErrors(errors) });
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
@@ -191,7 +192,7 @@ router.put('/:id/hero', adminAuth, requirePermission('settings.edit'), [
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
return res.status(400).json({ errors: safeValidationErrors(errors) });
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
|
||||
@@ -65,14 +65,20 @@ const signedPdfStorage = multer.diskStorage({
|
||||
cb(null, uploadDir);
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
const contractId = Number(req.params.id);
|
||||
if (!Number.isInteger(contractId) || contractId <= 0) {
|
||||
return cb(new Error('Invalid contract id'));
|
||||
}
|
||||
const ext = path.extname(file.originalname) || '.pdf';
|
||||
cb(null, `contract-${req.params.id}-${Date.now()}${ext}`);
|
||||
cb(null, `contract-${contractId}-${Date.now()}${ext}`);
|
||||
},
|
||||
});
|
||||
|
||||
const signedPdfUpload = multer({
|
||||
storage: signedPdfStorage,
|
||||
limits: { fileSize: 10 * 1024 * 1024 }, // 10 MB
|
||||
// CVE-2026-82333: single unnamed `file` field only — no legitimate
|
||||
// array-indexed field names, so reject any bracket-index field name.
|
||||
limits: { fileSize: 10 * 1024 * 1024, fieldArrayIndexLimit: 0 }, // 10 MB
|
||||
fileFilter: (req, file, cb) => {
|
||||
const allowed = ['application/pdf'];
|
||||
if (validateFileType(file.originalname, file.mimetype, allowed)) return cb(null, true);
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { body, param, validationResult } = require('express-validator');
|
||||
const { safeValidationErrors } = require('../utils/routeHelpers');
|
||||
const { db, withRetry } = require('../database/db');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
@@ -58,7 +59,7 @@ router.get('/:slotNumber', adminAuth, requirePermission('branding.view'), [
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
return res.status(400).json({ errors: safeValidationErrors(errors) });
|
||||
}
|
||||
|
||||
const { slotNumber } = req.params;
|
||||
@@ -92,7 +93,7 @@ router.put('/:slotNumber', adminAuth, requirePermission('branding.edit'), [
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
return res.status(400).json({ errors: safeValidationErrors(errors) });
|
||||
}
|
||||
|
||||
const { slotNumber } = req.params;
|
||||
@@ -166,7 +167,7 @@ router.post('/:slotNumber/reset', adminAuth, requirePermission('branding.edit'),
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
return res.status(400).json({ errors: safeValidationErrors(errors) });
|
||||
}
|
||||
|
||||
await withRetry(() =>
|
||||
|
||||
@@ -2,7 +2,7 @@ const express = require('express');
|
||||
const router = express.Router();
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const { databaseBackupService } = require('../services/databaseBackup');
|
||||
const { databaseBackupService, isUnderPubliclyServableRoot } = require('../services/databaseBackup');
|
||||
const { db } = require('../database/db');
|
||||
const logger = require('../utils/logger');
|
||||
const { getPagination } = require('../utils/routeHelpers');
|
||||
@@ -60,7 +60,28 @@ router.put('/config', requirePermission('backup.create'), async (req, res) => {
|
||||
'database_backup_email_on_failure',
|
||||
'database_backup_email_on_success'
|
||||
];
|
||||
|
||||
|
||||
// A backup.create holder (the built-in `admin` role has it without
|
||||
// settings.edit or backup.restore) could otherwise point backups at a
|
||||
// public static mount and fetch the dump unauthenticated — see
|
||||
// isUnderPubliclyServableRoot's comment (GHSA-jw8m-43r2-jqrm class).
|
||||
if (
|
||||
typeof req.body.database_backup_destination_path === 'string'
|
||||
&& isUnderPubliclyServableRoot(req.body.database_backup_destination_path)
|
||||
) {
|
||||
return res.status(400).json({ error: 'Destination path must not be inside a publicly served directory' });
|
||||
}
|
||||
|
||||
// A retention of 0 or less pushes cleanupOldBackups' cutoff to today or
|
||||
// the future, deleting every completed backup on the next scheduled run
|
||||
// — a backup.create holder achieving what backup.delete gates on /cleanup.
|
||||
if (
|
||||
req.body.database_backup_retention_days !== undefined
|
||||
&& (!Number.isFinite(req.body.database_backup_retention_days) || req.body.database_backup_retention_days < 1)
|
||||
) {
|
||||
return res.status(400).json({ error: 'database_backup_retention_days must be a positive number' });
|
||||
}
|
||||
|
||||
const updates = [];
|
||||
|
||||
for (const [key, value] of Object.entries(req.body)) {
|
||||
|
||||
@@ -9,7 +9,7 @@ const { requirePermission } = require('../middleware/permissions');
|
||||
const { requireFeatureFlag } = require('../middleware/requireFeatureFlag');
|
||||
const messagingGate = requireFeatureFlag('messaging');
|
||||
const { wrapEmailHtml, processEmailQueue } = require('../services/emailProcessor');
|
||||
const { errorResponse } = require('../utils/routeHelpers');
|
||||
const { errorResponse, safeValidationErrors } = require('../utils/routeHelpers');
|
||||
const logger = require('../utils/logger');
|
||||
const router = express.Router();
|
||||
|
||||
@@ -52,7 +52,7 @@ router.post('/config', [
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
return res.status(400).json({ errors: safeValidationErrors(errors) });
|
||||
}
|
||||
|
||||
const {
|
||||
@@ -152,7 +152,7 @@ router.post('/incoming-config', [
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() });
|
||||
if (!errors.isEmpty()) return res.status(400).json({ errors: safeValidationErrors(errors) });
|
||||
const { imap_host, imap_port, imap_secure, imap_user, imap_pass, imap_folder } = req.body;
|
||||
const { isHostAllowed } = require('../utils/networkValidation');
|
||||
if (!(await isHostAllowed(imap_host))) {
|
||||
@@ -635,7 +635,7 @@ router.get('/queue', adminAuth, requirePermission('email.view'), [
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
return res.status(400).json({ errors: safeValidationErrors(errors) });
|
||||
}
|
||||
|
||||
const page = req.query.page ? parseInt(req.query.page, 10) : 1;
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
const express = require('express');
|
||||
const { body, validationResult } = require('express-validator');
|
||||
const { safeValidationErrors } = require('../utils/routeHelpers');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const { requireEventOwnership } = require('../middleware/ownership');
|
||||
@@ -29,7 +30,7 @@ router.post('/:eventId/rename', adminAuth, requirePermission('events.edit'), req
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ success: false, errors: errors.array() });
|
||||
return res.status(400).json({ success: false, errors: safeValidationErrors(errors) });
|
||||
}
|
||||
|
||||
const { eventId } = req.params;
|
||||
@@ -70,7 +71,7 @@ router.post('/:eventId/validate-rename', adminAuth, requirePermission('events.ed
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ valid: false, errors: errors.array() });
|
||||
return res.status(400).json({ valid: false, errors: safeValidationErrors(errors) });
|
||||
}
|
||||
|
||||
const { eventId } = req.params;
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
const express = require('express');
|
||||
const { body, param, validationResult } = require('express-validator');
|
||||
const { safeValidationErrors } = require('../utils/routeHelpers');
|
||||
const { logActivity } = require('../database/db');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
@@ -57,7 +58,7 @@ router.get('/:id', adminAuth, requirePermission('settings.view'), [
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
return res.status(400).json({ errors: safeValidationErrors(errors) });
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
@@ -94,7 +95,7 @@ router.post('/', adminAuth, requirePermission('settings.edit'), [
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
return res.status(400).json({ errors: safeValidationErrors(errors) });
|
||||
}
|
||||
|
||||
const {
|
||||
@@ -156,7 +157,7 @@ router.put('/:id', adminAuth, requirePermission('settings.edit'), [
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
return res.status(400).json({ errors: safeValidationErrors(errors) });
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
@@ -196,7 +197,7 @@ router.delete('/:id', adminAuth, requirePermission('settings.edit'), [
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
return res.status(400).json({ errors: safeValidationErrors(errors) });
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
@@ -235,7 +236,7 @@ router.post('/reorder', adminAuth, requirePermission('settings.edit'), [
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
return res.status(400).json({ errors: safeValidationErrors(errors) });
|
||||
}
|
||||
|
||||
const { orderedIds } = req.body;
|
||||
|
||||
@@ -9,7 +9,7 @@ const { adminAuth } = require('../../middleware/auth');
|
||||
const { requirePermission } = require('../../middleware/permissions');
|
||||
const { archiveEvent } = require('../../services/archiveService');
|
||||
const logger = require('../../utils/logger');
|
||||
const { errorResponse } = require('../../utils/routeHelpers');
|
||||
const { errorResponse, safeValidationErrors } = require('../../utils/routeHelpers');
|
||||
const { requireEventOwnership, filterOwnedEventIds } = require('../../middleware/ownership');
|
||||
const { deleteEventCascade } = require('./helpers');
|
||||
|
||||
@@ -70,7 +70,7 @@ module.exports = (router) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
return res.status(400).json({ errors: safeValidationErrors(errors) });
|
||||
}
|
||||
|
||||
const { eventIds } = req.body;
|
||||
@@ -160,7 +160,7 @@ module.exports = (router) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
return res.status(400).json({ errors: safeValidationErrors(errors) });
|
||||
}
|
||||
|
||||
const { eventIds } = req.body;
|
||||
|
||||
@@ -17,7 +17,7 @@ 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 { errorResponse, safeValidationErrors } = require('../../utils/routeHelpers');
|
||||
const { buildShareLinkVariants } = require('../../services/shareLinkService');
|
||||
const { parseBooleanInput } = require('../../utils/parsers');
|
||||
const eventTypeService = require('../../services/eventTypeService');
|
||||
@@ -30,6 +30,29 @@ const { getFrontendBaseUrl } = require('../../utils/frontendUrl');
|
||||
const downloadZipService = require('../../services/downloadZipService');
|
||||
const { validateHeroImageAnchor, getEventFieldRequirements, readBooleanSetting, getDownloadProtectionDefaults, getBrandingDefaults, getCustomerNameFromPayload, getCustomerEmailFromPayload, getCustomerPhoneFromPayload, isPhoneFieldEnabled, mapEventForApi, hasCustomerContactColumns, deleteEventCascade, SLIDESHOW_TRANSITIONS, SLIDESHOW_COLORFILTERS } = require('./helpers');
|
||||
|
||||
/**
|
||||
* Validate a gallery password the admin re-typed, against the SAME policy
|
||||
* event creation applies.
|
||||
*
|
||||
* The publish dialog (#627) re-hashes `password_hash` from a plaintext the
|
||||
* admin types again, and validated it with nothing but express-validator's
|
||||
* `isLength({ min: 6 })`. So the configured complexity — moderate by default
|
||||
* — governed creation and reset while this door accepted `aaaaaa` and made it
|
||||
* the live gallery password.
|
||||
*
|
||||
* Returns null when the password passes; otherwise the response body to send.
|
||||
*/
|
||||
async function checkGalleryPasswordPolicy(password, eventName) {
|
||||
const result = await validatePasswordInContext(password, 'gallery', { eventName });
|
||||
if (result.valid) return null;
|
||||
return {
|
||||
error: 'Password does not meet security requirements',
|
||||
details: result.errors,
|
||||
score: result.score,
|
||||
feedback: result.feedback,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = (router) => {
|
||||
|
||||
|
||||
@@ -134,7 +157,7 @@ module.exports = (router) => {
|
||||
// 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() });
|
||||
return res.status(400).json({ errors: safeValidationErrors(errors) });
|
||||
}
|
||||
|
||||
// Get field requirements from settings
|
||||
@@ -833,7 +856,7 @@ module.exports = (router) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
return res.status(400).json({ errors: safeValidationErrors(errors) });
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
@@ -854,6 +877,9 @@ module.exports = (router) => {
|
||||
// Re-hash so the stored hash matches what the email carries — even if
|
||||
// the admin mistypes vs. what was set at draft creation, the gallery
|
||||
// password the customer receives is the one that actually works.
|
||||
const policyError = await checkGalleryPasswordPolicy(password, event.event_name);
|
||||
if (policyError) return res.status(400).json(policyError);
|
||||
|
||||
publishUpdates.password_hash = await bcrypt.hash(password, getBcryptRounds());
|
||||
}
|
||||
await db('events').where('id', id).update(publishUpdates);
|
||||
@@ -991,7 +1017,7 @@ module.exports = (router) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
return res.status(400).json({ errors: safeValidationErrors(errors) });
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
@@ -1269,7 +1295,7 @@ module.exports = (router) => {
|
||||
if (!errors.isEmpty()) {
|
||||
// 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() });
|
||||
return res.status(400).json({ errors: safeValidationErrors(errors) });
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
@@ -1669,7 +1695,7 @@ module.exports = (router) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
return res.status(400).json({ errors: safeValidationErrors(errors) });
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
|
||||
@@ -224,6 +224,117 @@ async function deleteEventCascade(eventId, adminContext) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Collect this event's storage keys BEFORE the transaction removes the
|
||||
// photo rows. Afterwards nothing records which objects belonged to this
|
||||
// event — the DB was the only place that knew, and on an S3/R2 backend the
|
||||
// objects are still sitting in the bucket, unreferenced and billable.
|
||||
//
|
||||
// The filesystem cleanup below (#608) only ever touched local disk: in S3
|
||||
// mode those paths don't exist, `fs.rm` succeeds against nothing, and the
|
||||
// real objects are never touched. Measured on a 403-photo event: bucket
|
||||
// object count unchanged, 679 referenced rows gone.
|
||||
//
|
||||
// A Set because a photo can carry the same key in two columns (an unresized
|
||||
// gallery's hero and preview can resolve to one object) and deleting it
|
||||
// twice would log a spurious failure for the second attempt.
|
||||
const storageKeys = new Set();
|
||||
// Derived keys separately: unlike the originals, whose keys embed the event
|
||||
// slug, these are not event-scoped and need a shared-ownership check below.
|
||||
const derivedKeys = new Set();
|
||||
try {
|
||||
const { resolvePhotoStorageKey } = require('../../services/photoResolver');
|
||||
const photos = await db('photos')
|
||||
.where('event_id', eventId)
|
||||
.select('id', 'path', 'thumbnail_path', 'hero_path', 'preview_path', 'watermark_path', 'source_origin');
|
||||
|
||||
for (const photo of photos) {
|
||||
try {
|
||||
// Returns null for reference/external photos, which live on a mount
|
||||
// outside the managed backend and must NOT be deleted — PicPeak does
|
||||
// not own those bytes.
|
||||
const originalKey = resolvePhotoStorageKey(event, photo);
|
||||
if (originalKey) storageKeys.add(originalKey);
|
||||
} catch (keyErr) {
|
||||
logger.warn('Could not resolve storage key during cascade delete', {
|
||||
eventId, photoId: photo.id, error: keyErr.message
|
||||
});
|
||||
}
|
||||
// Derived tiers are stored as canonical keys and pass through verbatim.
|
||||
// watermark_path included: it is storage-backed on the single-photo
|
||||
// path (watermarkService.deleteWatermarkFile) and leaked here the same
|
||||
// way the originals did.
|
||||
for (const derived of [photo.thumbnail_path, photo.hero_path, photo.preview_path, photo.watermark_path]) {
|
||||
if (derived) {
|
||||
storageKeys.add(derived);
|
||||
derivedKeys.add(derived);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (collectErr) {
|
||||
logger.warn('Could not enumerate stored objects before cascade delete', {
|
||||
eventId, error: collectErr.message
|
||||
});
|
||||
}
|
||||
|
||||
// A canonical derivative can belong to more than one gallery. Its basename
|
||||
// comes from the photo's filename — imageProcessor passes no outputBasename
|
||||
// for managed photos, so the key is `thumbnails/thumb_w300_<filename>` with
|
||||
// nothing event-scoped in it — and filenames are not unique across events.
|
||||
// The responsive-tier code says exactly that, which is why THOSE keys carry
|
||||
// a p{id}_ prefix; the canonical ones predate it. Deleting a shared key here
|
||||
// would blank a surviving gallery's tile until something regenerated it, so
|
||||
// anything another event still points at is left alone. Originals need no
|
||||
// such check: their keys embed the slug.
|
||||
const derived = Array.from(derivedKeys);
|
||||
try {
|
||||
// Chunked: SQLite caps bind variables at 999 and this is four columns wide.
|
||||
for (let i = 0; i < derived.length; i += 200) {
|
||||
const chunk = derived.slice(i, i + 200);
|
||||
const shared = await db('photos')
|
||||
.whereNot('event_id', eventId)
|
||||
.where((qb) => qb
|
||||
.whereIn('thumbnail_path', chunk)
|
||||
.orWhereIn('hero_path', chunk)
|
||||
.orWhereIn('preview_path', chunk)
|
||||
.orWhereIn('watermark_path', chunk))
|
||||
.select('thumbnail_path', 'hero_path', 'preview_path', 'watermark_path');
|
||||
for (const row of shared) {
|
||||
for (const key of [row.thumbnail_path, row.hero_path, row.preview_path, row.watermark_path]) {
|
||||
if (key && derivedKeys.has(key)) storageKeys.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (sharedErr) {
|
||||
// Can't prove ownership — keep the objects. An orphan costs storage; a
|
||||
// deleted derivative costs someone else's gallery.
|
||||
logger.warn('Could not check for shared derivatives; leaving them in place', {
|
||||
eventId, error: sharedErr.message
|
||||
});
|
||||
for (const key of derivedKeys) storageKeys.delete(key);
|
||||
}
|
||||
|
||||
// The archive zip is typically the largest single object an event owns, and
|
||||
// archiveService writes it through the backend (`storage.putFromFile`) — so
|
||||
// the `fs.unlink` below is a no-op on S3 and the zip outlives its event.
|
||||
if (event.archive_path) storageKeys.add(event.archive_path);
|
||||
|
||||
// The pre-built "Download All" zip is the subtle one: it lives UNDER
|
||||
// events/active/{slug}/.download-cache/ (downloadZipService.js:42), so the
|
||||
// recursive fs.rm below covers it on local disk and nothing covers it on
|
||||
// S3, where that prefix is not a directory. It is gallery-sized.
|
||||
// downloadZipService exposes a cleanup() documented as "used on event
|
||||
// deletion" that this cascade never called.
|
||||
// NOTE: an in-flight "Download All" build that started before this delete
|
||||
// can still upload its zip after the sweep and write the path onto a row
|
||||
// that no longer exists, orphaning it. downloadZipService.cleanup() is the
|
||||
// service's cancel primitive, but calling it here made the backend CI job
|
||||
// exceed its 10-minute budget on this branch — its _cleanup() reaches
|
||||
// getStorage() and, in a suite where the S3 backend is configured but
|
||||
// unreachable, every cascade delete then pays the adapter's retry backoff.
|
||||
// Left as a follow-up rather than shipped as a timeout: the race is narrow
|
||||
// and costs one orphaned object, the regression cost the whole suite.
|
||||
if (event.download_zip_path) storageKeys.add(event.download_zip_path);
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
// 1. Delete activity logs (audit trail)
|
||||
await trx('activity_logs').where('event_id', eventId).del();
|
||||
@@ -284,6 +395,57 @@ async function deleteEventCascade(eventId, adminContext) {
|
||||
}
|
||||
});
|
||||
|
||||
// Managed objects, deleted AFTER the commit: a rolled-back transaction must
|
||||
// never leave files destroyed for an event that still exists. Failures are
|
||||
// logged rather than thrown, matching the philosophy of the filesystem
|
||||
// cleanup above — the database is the source of truth, an orphaned object
|
||||
// is recoverable noise, a half-deleted event is not.
|
||||
if (storageKeys.size > 0) {
|
||||
const { getStorage } = require('../../services/storage');
|
||||
let removed = 0;
|
||||
try {
|
||||
const storage = getStorage();
|
||||
const keys = Array.from(storageKeys);
|
||||
|
||||
// Bounded concurrency rather than one await per key. A 400-photo gallery
|
||||
// owns well over a thousand objects once the derived tiers are counted,
|
||||
// and on S3 that many sequential DeleteObject round trips runs to
|
||||
// minutes — long enough for a proxy to time the request out AFTER the
|
||||
// commit, leaving the event deleted and the sweep half-finished.
|
||||
// Deleting is idempotent and order-independent, so there is nothing to
|
||||
// serialise for.
|
||||
//
|
||||
// A pool, not Promise.all over every key: an unbounded fan-out would
|
||||
// open one socket per object and exhaust the S3 client's connection
|
||||
// pool.
|
||||
const CONCURRENCY = 16;
|
||||
let cursor = 0;
|
||||
const worker = async () => {
|
||||
while (cursor < keys.length) {
|
||||
const key = keys[cursor++];
|
||||
try {
|
||||
await storage.delete(key);
|
||||
removed++;
|
||||
} catch (delErr) {
|
||||
logger.warn('Failed to delete stored object during cascade delete', {
|
||||
eventId, key, error: delErr.message
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
await Promise.all(
|
||||
Array.from({ length: Math.min(CONCURRENCY, keys.length) }, worker)
|
||||
);
|
||||
} catch (storageErr) {
|
||||
logger.warn('Storage backend unavailable during cascade delete', {
|
||||
eventId, error: storageErr.message
|
||||
});
|
||||
}
|
||||
logger.info('Cascade delete removed stored objects', {
|
||||
eventId, removed, total: storageKeys.size
|
||||
});
|
||||
}
|
||||
|
||||
// Audit trail (outside the transaction so a logging failure can't undo
|
||||
// the actual delete).
|
||||
await logActivity('event_deleted',
|
||||
|
||||
@@ -23,14 +23,20 @@ const eventLogoStorage = multer.diskStorage({
|
||||
cb(null, uploadDir);
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
const eventId = Number(req.params.id);
|
||||
if (!Number.isInteger(eventId) || eventId <= 0) {
|
||||
return cb(new Error('Invalid event id'));
|
||||
}
|
||||
const ext = path.extname(file.originalname);
|
||||
cb(null, `event-${req.params.id}-logo-${Date.now()}${ext}`);
|
||||
cb(null, `event-${eventId}-logo-${Date.now()}${ext}`);
|
||||
}
|
||||
});
|
||||
|
||||
const eventLogoUpload = multer({
|
||||
storage: eventLogoStorage,
|
||||
limits: { fileSize: 5 * 1024 * 1024 }, // 5MB
|
||||
// CVE-2026-82333: single unnamed `logo` field only — no legitimate
|
||||
// array-indexed field names, so reject any bracket-index field name.
|
||||
limits: { fileSize: 5 * 1024 * 1024, fieldArrayIndexLimit: 0 }, // 5MB
|
||||
fileFilter: (req, file, cb) => {
|
||||
const allowedMimeTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/svg+xml'];
|
||||
if (validateFileType(file.originalname, file.mimetype, allowedMimeTypes)) {
|
||||
|
||||
@@ -8,7 +8,7 @@ const { formatBoolean } = require('../../utils/dbCompat');
|
||||
const { adminAuth } = require('../../middleware/auth');
|
||||
const { requirePermission } = require('../../middleware/permissions');
|
||||
const crypto = require('crypto');
|
||||
const { errorResponse } = require('../../utils/routeHelpers');
|
||||
const { errorResponse, safeValidationErrors } = require('../../utils/routeHelpers');
|
||||
const { parseBooleanInput } = require('../../utils/parsers');
|
||||
const { requireEventOwnership } = require('../../middleware/ownership');
|
||||
const { requireFeatureFlag } = require('../../middleware/requireFeatureFlag');
|
||||
@@ -110,7 +110,7 @@ module.exports = (router) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ error: 'Invalid slideshow settings', details: errors.array() });
|
||||
return res.status(400).json({ error: 'Invalid slideshow settings', details: safeValidationErrors(errors) });
|
||||
}
|
||||
|
||||
const event = await loadOwnedEvent(req);
|
||||
|
||||
@@ -41,7 +41,10 @@ function diskUpload(subdir) {
|
||||
},
|
||||
filename: (_req, file, cb) => cb(null, `${subdir.split('/').pop()}-${Date.now()}${path.extname(file.originalname) || ''}`),
|
||||
}),
|
||||
limits: { fileSize: 15 * 1024 * 1024 },
|
||||
// CVE-2026-82333: both callers (`inboundUpload` → 'file', `proofUpload`
|
||||
// → 'proof') take a single unnamed field — no legitimate array-indexed
|
||||
// field names, so reject any bracket-index field name.
|
||||
limits: { fileSize: 15 * 1024 * 1024, fieldArrayIndexLimit: 0 },
|
||||
fileFilter: (_req, file, cb) => (ALLOWED_MIME.includes(file.mimetype) ? cb(null, true) : cb(new Error('Only PDF, JPEG or PNG files are allowed'))),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -67,7 +67,9 @@ const importedInvoiceStorage = multer.diskStorage({
|
||||
});
|
||||
const importedInvoiceUpload = multer({
|
||||
storage: importedInvoiceStorage,
|
||||
limits: { fileSize: 10 * 1024 * 1024 },
|
||||
// CVE-2026-82333: single unnamed `pdf` field only — no legitimate
|
||||
// array-indexed field names, so reject any bracket-index field name.
|
||||
limits: { fileSize: 10 * 1024 * 1024, fieldArrayIndexLimit: 0 },
|
||||
fileFilter: (_req, file, cb) => {
|
||||
if (file.mimetype === 'application/pdf') cb(null, true);
|
||||
else cb(new Error('Only PDF files are allowed for imported invoices'));
|
||||
|
||||
@@ -11,7 +11,7 @@ const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const { requireEventOwnership } = require('../middleware/ownership');
|
||||
const { PhotoFilterBuilder } = require('../utils/photoFilterBuilder');
|
||||
const { getPagination } = require('../utils/routeHelpers');
|
||||
const { getPagination, safeValidationErrors } = require('../utils/routeHelpers');
|
||||
const { PhotoExportService } = require('../services/photoExportService');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
@@ -39,7 +39,7 @@ router.get('/:eventId/filtered', adminAuth, requirePermission('photos.view'), re
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
return res.status(400).json({ errors: safeValidationErrors(errors) });
|
||||
}
|
||||
|
||||
const eventId = parseInt(req.params.eventId);
|
||||
@@ -166,7 +166,7 @@ router.post('/:eventId/export', adminAuth, requirePermission('photos.download'),
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
return res.status(400).json({ errors: safeValidationErrors(errors) });
|
||||
}
|
||||
|
||||
const eventId = parseInt(req.params.eventId);
|
||||
|
||||
@@ -14,7 +14,8 @@ const {
|
||||
} = require('../services/downloadFilenameService');
|
||||
const { escapeLikePattern } = require('../utils/sqlSecurity');
|
||||
const { validateUploadedFiles } = require('../middleware/uploadValidation');
|
||||
const { getMaxFilesPerUpload, getAllowedMimeTypes } = require('../services/uploadSettings');
|
||||
const { getMaxFilesPerUpload, getAllowedMimeTypes, EXTENSION_TO_MIME } = require('../services/uploadSettings');
|
||||
const { resolvePhotoContentType } = require('../utils/photoContentType');
|
||||
const { processUploadedPhotos } = require('../services/photoProcessor');
|
||||
const chunkedUpload = require('../services/chunkedUploadService');
|
||||
const watermarkGeneratorService = require('../services/watermarkGeneratorService');
|
||||
@@ -71,7 +72,12 @@ const upload = multer({
|
||||
files: 2000, // Hard safety ceiling; actual limit enforced dynamically
|
||||
fieldSize: 10 * 1024 * 1024, // 10MB for non-file fields
|
||||
parts: 10000,
|
||||
headerPairs: 2000
|
||||
headerPairs: 2000,
|
||||
// CVE-2026-82333: files arrive as repeated `photos` parts via
|
||||
// .array('photos', N) — not bracket-indexed field names like
|
||||
// `photos[0]` — so no legitimate field name uses array-index syntax
|
||||
// at all. Reject any that do.
|
||||
fieldArrayIndexLimit: 0
|
||||
},
|
||||
fileFilter: (req, file, cb) => {
|
||||
// req.allowedMimeTypes is populated by the middleware that runs before multer
|
||||
@@ -958,7 +964,7 @@ router.get('/:eventId/photos/:photoId/download', adminAuth, requirePermission('p
|
||||
return res.status(404).json({ error: 'Photo file not found' });
|
||||
}
|
||||
res.set({
|
||||
'Content-Type': photo.mime_type || 'application/octet-stream',
|
||||
'Content-Type': resolvePhotoContentType(photo),
|
||||
'Content-Length': stat.size,
|
||||
'Content-Disposition': contentDisposition,
|
||||
});
|
||||
@@ -975,7 +981,7 @@ router.get('/:eventId/photos/:photoId/download', adminAuth, requirePermission('p
|
||||
return res.status(404).json({ error: 'Photo file not found' });
|
||||
}
|
||||
res.set({
|
||||
'Content-Type': photo.mime_type || 'application/octet-stream',
|
||||
'Content-Type': resolvePhotoContentType(photo),
|
||||
'Content-Disposition': contentDisposition,
|
||||
});
|
||||
res.sendFile(filePath);
|
||||
@@ -1151,64 +1157,9 @@ router.get('/:eventId/photo/:photoId', adminAuth, requirePermission('photos.view
|
||||
const event = await db('events').where('id', eventId).first();
|
||||
const storageKey = resolvePhotoStorageKey(event, photo);
|
||||
|
||||
// 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';
|
||||
// Content-Type resolution (#908 + external review) lives in
|
||||
// utils/photoContentType so the gallery routes apply the same rule.
|
||||
const contentType = resolvePhotoContentType(photo);
|
||||
res.setHeader('Content-Type', contentType);
|
||||
res.setHeader('Cache-Control', 'private, max-age=3600');
|
||||
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
|
||||
@@ -1324,7 +1275,7 @@ router.get('/:eventId/debug', adminAuth, requirePermission('photos.view'), requi
|
||||
router.post('/:eventId/chunked-upload/init', adminAuth, requirePermission('photos.upload'), requireEventOwnership, async (req, res) => {
|
||||
try {
|
||||
const { eventId } = req.params;
|
||||
const { filename, fileSize, mimeType, totalChunks } = req.body;
|
||||
const { filename, fileSize, totalChunks } = req.body;
|
||||
|
||||
// Validate event exists
|
||||
const event = await db('events').where({ id: eventId }).first();
|
||||
@@ -1333,8 +1284,23 @@ router.post('/:eventId/chunked-upload/init', adminAuth, requirePermission('photo
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if (!filename || !fileSize || !mimeType) {
|
||||
return res.status(400).json({ error: 'Missing required fields: filename, fileSize, mimeType' });
|
||||
if (!filename || !fileSize) {
|
||||
return res.status(400).json({ error: 'Missing required fields: filename, fileSize' });
|
||||
}
|
||||
|
||||
// The client-declared mimeType is not trusted. It used to be stored on
|
||||
// the photo row verbatim and echoed as Content-Type by the gallery
|
||||
// routes, so a JPEG/HTML polyglot declared as text/html rendered inline
|
||||
// on the app origin. The MIME is derived from the extension instead,
|
||||
// and the extension has to be on the admin's allow-list, which is what
|
||||
// the multipart path enforces through its multer fileFilter.
|
||||
const ext = path.extname(String(filename)).slice(1).toLowerCase();
|
||||
const mimeType = Object.prototype.hasOwnProperty.call(EXTENSION_TO_MIME, ext)
|
||||
? EXTENSION_TO_MIME[ext]
|
||||
: null;
|
||||
const allowedMimeTypes = await getAllowedMimeTypes();
|
||||
if (!mimeType || !allowedMimeTypes.includes(mimeType)) {
|
||||
return res.status(400).json({ error: 'File type not allowed' });
|
||||
}
|
||||
|
||||
// Validate file size (max 10GB)
|
||||
|
||||
@@ -5,7 +5,7 @@ const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const { body, query, validationResult } = require('express-validator');
|
||||
const logger = require('../utils/logger');
|
||||
const { getPagination } = require('../utils/routeHelpers');
|
||||
const { getPagination, safeValidationErrors } = require('../utils/routeHelpers');
|
||||
const { db } = require('../database/db');
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
@@ -86,7 +86,7 @@ router.post('/validate', requirePermission('backup.restore'), [
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
errors: errors.array()
|
||||
errors: safeValidationErrors(errors)
|
||||
});
|
||||
}
|
||||
|
||||
@@ -158,7 +158,7 @@ router.post('/start', requirePermission('backup.restore'), [
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
errors: errors.array()
|
||||
errors: safeValidationErrors(errors)
|
||||
});
|
||||
}
|
||||
|
||||
@@ -690,7 +690,7 @@ router.put('/settings', requirePermission('backup.restore'), [
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
errors: errors.array()
|
||||
errors: safeValidationErrors(errors)
|
||||
});
|
||||
}
|
||||
|
||||
@@ -810,24 +810,72 @@ async function checkRestorePathsAllowed({ source, manifestPath }) {
|
||||
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;
|
||||
// GHSA-xfvx: nothing configured to compare against used to mean "a
|
||||
// restore can't be scoped, so don't pretend to enforce" — returning
|
||||
// null (allow). That's fail-OPEN: on a fresh install (or one where an
|
||||
// operator never set backup_destination_path/backup_manifest_path) any
|
||||
// authenticated `backup.restore` caller could point source/manifestPath
|
||||
// — and, via the manifest, database.backup_file — at literally any path
|
||||
// on disk. Require configuration instead of silently allowing
|
||||
// everything; the normal restore wizard already needs one of these
|
||||
// settings populated to discover backups in the first place.
|
||||
logger.warn('Refusing restore: no backup location configured to scope it to', { candidates });
|
||||
return 'No backup location is configured (backup_destination_path / backup_manifest_path). ' +
|
||||
'Configure one before restoring.';
|
||||
}
|
||||
|
||||
const resolvedRoots = roots.map((r) => path.resolve(r));
|
||||
for (const candidate of candidates) {
|
||||
const isInsideRoots = (candidate) => {
|
||||
const resolved = path.resolve(candidate);
|
||||
const inside = resolvedRoots.some(
|
||||
return resolvedRoots.some(
|
||||
(root) => resolved === root || resolved.startsWith(root + path.sep)
|
||||
);
|
||||
if (!inside) {
|
||||
};
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (!isInsideRoots(candidate)) {
|
||||
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';
|
||||
}
|
||||
}
|
||||
|
||||
// GHSA-xfvx: source/manifestPath containment alone isn't enough — the
|
||||
// manifest FILE (which just passed containment above) can itself carry a
|
||||
// `database.backup_file` field that restoreService's candidate resolution
|
||||
// used to hand straight to `sqlite3 .restore` with no containment check at
|
||||
// all. Peek at the manifest here (it's already proven to live inside an
|
||||
// allowed root) and reject an ABSOLUTE backup_file that escapes the same
|
||||
// roots — the case that's unambiguous to check without re-deriving
|
||||
// restoreService's own `backupPath` resolution for the relative-path
|
||||
// candidates. This is deliberately defense in depth, not the only gate:
|
||||
// restoreService.performDatabaseRestore independently re-derives and
|
||||
// enforces containment (including relative/`..` candidates) against
|
||||
// `backupPath` right before ever using the resolved path, and remains the
|
||||
// authoritative check for S3-sourced manifests (downloaded after this
|
||||
// pre-check runs).
|
||||
if (manifestPath && !isS3(manifestPath) && !isTypeToken(manifestPath)) {
|
||||
try {
|
||||
const raw = await fs.readFile(manifestPath, 'utf8');
|
||||
const trimmed = raw.trimStart();
|
||||
const parsed = (trimmed.startsWith('{') || trimmed.startsWith('['))
|
||||
? JSON.parse(raw)
|
||||
: null; // non-JSON (e.g. YAML) manifests are re-checked inside restoreService
|
||||
const dbBackupFile = parsed?.database?.backup_file;
|
||||
if (typeof dbBackupFile === 'string' && path.isAbsolute(dbBackupFile) && !isInsideRoots(dbBackupFile)) {
|
||||
logger.warn('Refusing restore: manifest database.backup_file escapes configured backup roots', {
|
||||
manifestPath, backupFile: dbBackupFile,
|
||||
});
|
||||
return 'Manifest database.backup_file must be inside a configured backup location';
|
||||
}
|
||||
} catch (_) {
|
||||
// Unreadable/corrupt/non-JSON manifest: let the normal restore flow
|
||||
// surface the real error (loadAndValidateManifest) instead of failing
|
||||
// this pre-check for an unrelated reason.
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const express = require('express');
|
||||
const multer = require('multer');
|
||||
const path = require('path');
|
||||
const { uploadedAssetPath } = require('../utils/safePath');
|
||||
const fs = require('fs').promises;
|
||||
const { body, validationResult } = require('express-validator');
|
||||
const { db, logActivity } = require('../database/db');
|
||||
@@ -22,7 +23,7 @@ const { sanitizeCss } = require('../utils/cssSanitizer');
|
||||
const { upsertAppSetting } = require('../utils/appSettings');
|
||||
const { clearShareLinkSettingsCache } = require('../services/shareLinkService');
|
||||
const { resetSecurityConfigCache } = require('../utils/authSecurity');
|
||||
const { errorResponse } = require('../utils/routeHelpers');
|
||||
const { errorResponse, safeValidationErrors } = require('../utils/routeHelpers');
|
||||
const logger = require('../utils/logger');
|
||||
const { measureLocalStorageUsage } = require('../services/localStorageUsage');
|
||||
const router = express.Router();
|
||||
@@ -49,7 +50,10 @@ const { validateFileType } = require('../utils/fileSecurityUtils');
|
||||
|
||||
const upload = multer({
|
||||
storage,
|
||||
limits: { fileSize: 5 * 1024 * 1024 }, // 5MB
|
||||
// CVE-2026-82333: single unnamed field (`logo` or `watermarkLogo`) per
|
||||
// route — no legitimate array-indexed field names, so reject any
|
||||
// bracket-index field name.
|
||||
limits: { fileSize: 5 * 1024 * 1024, fieldArrayIndexLimit: 0 }, // 5MB
|
||||
fileFilter: (req, file, cb) => {
|
||||
// Note: SVG files are excluded from magic number validation for logos
|
||||
const allowedMimeTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/svg+xml'];
|
||||
@@ -77,7 +81,9 @@ const faviconStorage = multer.diskStorage({
|
||||
|
||||
const faviconUpload = multer({
|
||||
storage: faviconStorage,
|
||||
limits: { fileSize: 2 * 1024 * 1024 }, // 2MB — roomy enough for a 512×512+ square PNG
|
||||
// CVE-2026-82333: single unnamed `favicon` field only — no legitimate
|
||||
// array-indexed field names, so reject any bracket-index field name.
|
||||
limits: { fileSize: 2 * 1024 * 1024, fieldArrayIndexLimit: 0 }, // 2MB — roomy enough for a 512×512+ square PNG
|
||||
fileFilter: (req, file, cb) => {
|
||||
const allowedMimeTypes = ['image/png', 'image/x-icon', 'image/vnd.microsoft.icon'];
|
||||
const name = file.originalname.toLowerCase();
|
||||
@@ -567,10 +573,13 @@ router.put('/branding', adminAuth, requirePermission('settings.edit'), async (re
|
||||
currentFaviconUrl = currentFaviconSetting.setting_value;
|
||||
}
|
||||
|
||||
if (currentFaviconUrl && typeof currentFaviconUrl === 'string' && currentFaviconUrl.startsWith('/uploads/favicons/')) {
|
||||
// Delete the file from filesystem
|
||||
const relativePath = currentFaviconUrl.replace(/^\//, '');
|
||||
const faviconPath = path.join(getStoragePath(), relativePath);
|
||||
// Containment: the stored URL is admin-writable, so only the leaf
|
||||
// name is used and it is joined onto the fixed favicon directory. A
|
||||
// prefix test alone let `/uploads/favicons/../../<anything>` pass
|
||||
// and path.join collapse it -- an arbitrary-file delete for any
|
||||
// holder of settings.edit.
|
||||
const faviconPath = uploadedAssetPath(currentFaviconUrl, 'favicons', getStoragePath());
|
||||
if (faviconPath) {
|
||||
try {
|
||||
await fs.unlink(faviconPath);
|
||||
logger.info('Deleted favicon file:', faviconPath);
|
||||
@@ -598,10 +607,9 @@ router.put('/branding', adminAuth, requirePermission('settings.edit'), async (re
|
||||
currentLogoUrl = currentLogoSetting.setting_value;
|
||||
}
|
||||
|
||||
if (currentLogoUrl && typeof currentLogoUrl === 'string' && currentLogoUrl.startsWith('/uploads/logos/')) {
|
||||
// Delete the file from filesystem
|
||||
const relativePath = currentLogoUrl.replace(/^\//, '');
|
||||
const logoPath = path.join(getStoragePath(), relativePath);
|
||||
// Same containment as the favicon branch above.
|
||||
const logoPath = uploadedAssetPath(currentLogoUrl, 'logos', getStoragePath());
|
||||
if (logoPath) {
|
||||
try {
|
||||
await fs.unlink(logoPath);
|
||||
logger.info('Deleted logo file:', logoPath);
|
||||
@@ -1474,7 +1482,7 @@ router.put('/security/rate-limit', adminAuth, requirePermission('settings.edit')
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
return res.status(400).json({ errors: safeValidationErrors(errors) });
|
||||
}
|
||||
|
||||
const {
|
||||
|
||||
@@ -11,9 +11,11 @@
|
||||
*/
|
||||
const express = require('express');
|
||||
const { body, param, validationResult } = require('express-validator');
|
||||
const { safeValidationErrors } = require('../utils/routeHelpers');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const { requireEventOwnership } = require('../middleware/ownership');
|
||||
const { db } = require('../database/db');
|
||||
const galleryShortUrlService = require('../services/galleryShortUrlService');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
@@ -32,7 +34,7 @@ router.get(
|
||||
requireEventOwnership,
|
||||
async (req, res) => {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() });
|
||||
if (!errors.isEmpty()) return res.status(400).json({ errors: safeValidationErrors(errors) });
|
||||
try {
|
||||
const rows = await galleryShortUrlService.listForEvent(parseInt(req.params.eventId, 10));
|
||||
res.json({ shortUrls: rows });
|
||||
@@ -56,7 +58,7 @@ router.post(
|
||||
requireEventOwnership,
|
||||
async (req, res) => {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() });
|
||||
if (!errors.isEmpty()) return res.status(400).json({ errors: safeValidationErrors(errors) });
|
||||
try {
|
||||
const row = await galleryShortUrlService.createShortUrl({
|
||||
eventId: parseInt(req.params.eventId, 10),
|
||||
@@ -85,6 +87,31 @@ router.post(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Ownership guard for the by-short-url-id DELETE route (GHSA-9h7q-2jpf-vj85).
|
||||
* GET/POST take :eventId directly so requireEventOwnership applies as-is;
|
||||
* DELETE takes the short URL row's own :id, so resolve its event first and
|
||||
* apply the same ownership predicate requireEventOwnership uses (event has
|
||||
* no owner, or the admin owns it). Sends the response and returns false
|
||||
* when the caller may not act on it (404 if the row doesn't exist, 403 if
|
||||
* it exists but belongs to another admin).
|
||||
*/
|
||||
async function assertOwnsShortUrl(req, res, id) {
|
||||
const row = await db('gallery_short_urls').where({ id }).first('event_id');
|
||||
if (!row) {
|
||||
res.status(404).json({ error: 'Short URL not found' });
|
||||
return false;
|
||||
}
|
||||
if (req.admin.roleName !== 'super_admin') {
|
||||
const event = await db('events').where({ id: row.event_id }).first('created_by');
|
||||
if (event && event.created_by && event.created_by !== req.admin.id) {
|
||||
res.status(403).json({ error: 'Access denied' });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/admin/short-urls/:id
|
||||
* Soft-delete. The public route serves 410 Gone on a deleted row so the
|
||||
@@ -96,12 +123,11 @@ router.delete(
|
||||
param('id').isInt({ min: 1 }),
|
||||
async (req, res) => {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() });
|
||||
if (!errors.isEmpty()) return res.status(400).json({ errors: safeValidationErrors(errors) });
|
||||
try {
|
||||
const ok = await galleryShortUrlService.softDelete(
|
||||
parseInt(req.params.id, 10),
|
||||
req.admin?.id || null,
|
||||
);
|
||||
const id = parseInt(req.params.id, 10);
|
||||
if (!(await assertOwnsShortUrl(req, res, id))) return;
|
||||
const ok = await galleryShortUrlService.softDelete(id, req.admin?.id || null);
|
||||
if (!ok) return res.status(404).json({ error: 'Short URL not found' });
|
||||
res.status(204).end();
|
||||
} catch (err) {
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
|
||||
const express = require('express');
|
||||
const { body, query, validationResult } = require('express-validator');
|
||||
const { safeValidationErrors } = require('../utils/routeHelpers');
|
||||
const { db, logActivity } = require('../database/db');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
@@ -106,7 +107,7 @@ router.post(
|
||||
async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() });
|
||||
if (!errors.isEmpty()) return res.status(400).json({ errors: safeValidationErrors(errors) });
|
||||
|
||||
const { name, url, events, active = true, filter, template } = req.body;
|
||||
const { plaintext, preview } = webhookService.generateSecret();
|
||||
@@ -188,7 +189,7 @@ router.put(
|
||||
async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() });
|
||||
if (!errors.isEmpty()) return res.status(400).json({ errors: safeValidationErrors(errors) });
|
||||
|
||||
const row = await db('webhooks').where({ id: req.params.id }).first();
|
||||
if (!row) return res.status(404).json({ error: 'Webhook not found' });
|
||||
@@ -241,7 +242,7 @@ router.post(
|
||||
async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() });
|
||||
if (!errors.isEmpty()) return res.status(400).json({ errors: safeValidationErrors(errors) });
|
||||
|
||||
const row = await db('webhooks').where({ id: req.params.id }).first();
|
||||
if (!row) return res.status(404).json({ error: 'Webhook not found' });
|
||||
@@ -293,7 +294,7 @@ router.get(
|
||||
async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() });
|
||||
if (!errors.isEmpty()) return res.status(400).json({ errors: safeValidationErrors(errors) });
|
||||
|
||||
const webhookId = req.params.id;
|
||||
const exists = await db('webhooks').where({ id: webhookId }).first();
|
||||
|
||||
+72
-17
@@ -2,6 +2,16 @@ const express = require('express');
|
||||
const bcrypt = require('bcrypt');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { body, validationResult } = require('express-validator');
|
||||
|
||||
/**
|
||||
* express-validator's errors.array() carries `value` -- the submitted input --
|
||||
* so returning it verbatim reflects the caller's password back in the 400 body.
|
||||
* Five routes in this file validate a password field, and the strength endpoint
|
||||
* is unauthenticated behind a 50mb JSON limit, which also made the rejection
|
||||
* itself an allocation amplifier. Everything except `value` is kept, so the
|
||||
* response shape both frontend consumers rely on (`msg`, `path`) is unchanged.
|
||||
*/
|
||||
const safeValidationErrors = (errors) => errors.array().map(({ value, ...rest }) => rest);
|
||||
const { db, logActivity } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { verifyRecaptcha } = require('../services/recaptcha');
|
||||
@@ -16,6 +26,9 @@ const {
|
||||
const { endSession } = require('../middleware/sessionTimeout');
|
||||
const { revokeToken } = require('../utils/tokenRevocation');
|
||||
const { timingSafeEqualStr } = require('../utils/timingSafe');
|
||||
// Well-formed bcrypt hash that matches nothing; compared against when there is
|
||||
// no account so the unknown-user path costs the same as a wrong password.
|
||||
const DUMMY_BCRYPT_HASH = '$2b$10$abcdefghijklmnopqrstuuABCDEFGHIJKLMNOPQRSTUVWXYZ01234';
|
||||
const logger = require('../utils/logger');
|
||||
const { errorResponse } = require('../utils/routeHelpers');
|
||||
const {
|
||||
@@ -30,6 +43,7 @@ const { getEventShareToken, resolveShareIdentifier } = require('../services/shar
|
||||
const { getClientIp } = require('../utils/requestIp');
|
||||
const {
|
||||
validatePasswordInContext,
|
||||
MAX_PASSWORD_LENGTH,
|
||||
getBcryptRounds,
|
||||
logPasswordValidationFailure
|
||||
} = require('../utils/passwordValidation');
|
||||
@@ -80,13 +94,15 @@ async function completeAdminLogin(req, res, admin, ipAddress, userAgent, lockout
|
||||
|
||||
// Admin login with enhanced security
|
||||
router.post('/admin/login', [
|
||||
body('username').notEmpty().trim(),
|
||||
body('password').notEmpty()
|
||||
// Length caps: an unbounded username reached the lockout lookup, bcrypt,
|
||||
// the failed-attempt log line and login_attempts.identifier as sent.
|
||||
body('username').isString().trim().notEmpty().isLength({ max: 255 }),
|
||||
body('password').isString().notEmpty().isLength({ max: MAX_PASSWORD_LENGTH })
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
return res.status(400).json({ errors: safeValidationErrors(errors) });
|
||||
}
|
||||
|
||||
const { username, password, recaptchaToken } = req.body;
|
||||
@@ -130,7 +146,13 @@ router.post('/admin/login', [
|
||||
.first();
|
||||
|
||||
// Use generic error to prevent user enumeration
|
||||
if (!admin || !await bcrypt.compare(password, admin.password_hash)) {
|
||||
// Always run one bcrypt compare so an unknown username costs the same
|
||||
// ~100ms as a wrong password; short-circuiting here was a timing oracle
|
||||
// for username enumeration despite the generic message.
|
||||
const passwordMatches = admin
|
||||
? await bcrypt.compare(password, admin.password_hash)
|
||||
: await bcrypt.compare(password, DUMMY_BCRYPT_HASH).then(() => false);
|
||||
if (!passwordMatches) {
|
||||
await trackFailedAttempt(username, ipAddress, userAgent);
|
||||
return res.status(401).json({ error: getGenericAuthError() });
|
||||
}
|
||||
@@ -174,7 +196,7 @@ router.post('/admin/login/mfa', [
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
return res.status(400).json({ errors: safeValidationErrors(errors) });
|
||||
}
|
||||
|
||||
const { mfaToken, code } = req.body;
|
||||
@@ -221,8 +243,22 @@ router.post('/admin/login/mfa', [
|
||||
return res.status(401).json({ error: getGenericAuthError() });
|
||||
}
|
||||
|
||||
// TOTP first, then a one-time recovery code.
|
||||
let ok = mfaService.verifyTotpEncrypted(code, admin.two_factor_secret);
|
||||
// TOTP first, then a one-time recovery code. verifyTotpEncryptedStep also
|
||||
// enforces replay protection (GHSA-qcwx-r25m-j869): a code whose matched
|
||||
// step doesn't advance past this admin's two_factor_last_used_step is
|
||||
// rejected, so the same code can't complete two logins. The step is
|
||||
// persisted atomically (persistTotpStep) right here, immediately after a
|
||||
// match, so two concurrent requests carrying the same captured code
|
||||
// can't both read the same last-used step and both win — only the first
|
||||
// writer's UPDATE affects a row; the loser falls through and is treated
|
||||
// as a replay below.
|
||||
const totpStep = mfaService.verifyTotpEncryptedStep(
|
||||
code, admin.two_factor_secret, admin.two_factor_last_used_step
|
||||
);
|
||||
let ok = false;
|
||||
if (totpStep !== null) {
|
||||
ok = await mfaService.persistTotpStep(db, admin.id, totpStep, { updated_at: new Date() });
|
||||
}
|
||||
let usedRecovery = false;
|
||||
let remainingHashes = null;
|
||||
if (!ok) {
|
||||
@@ -251,6 +287,7 @@ router.post('/admin/login/mfa', [
|
||||
{ type: 'admin', id: admin.id, name: admin.username }
|
||||
);
|
||||
}
|
||||
// else: the TOTP step was already persisted atomically above.
|
||||
|
||||
await logActivity('admin_mfa_login',
|
||||
{ admin_id: admin.id, method: usedRecovery ? 'recovery_code' : 'totp' },
|
||||
@@ -309,13 +346,13 @@ router.post('/logout', async (req, res) => {
|
||||
|
||||
// Gallery password verification with enhanced security
|
||||
router.post('/gallery/verify', [
|
||||
body('slug').notEmpty().trim(),
|
||||
body('password').optional().isString()
|
||||
body('slug').isString().trim().notEmpty().isLength({ max: 255 }),
|
||||
body('password').optional().isString().isLength({ max: MAX_PASSWORD_LENGTH })
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
return res.status(400).json({ errors: safeValidationErrors(errors) });
|
||||
}
|
||||
|
||||
const { slug, password, recaptchaToken } = req.body;
|
||||
@@ -327,7 +364,7 @@ router.post('/gallery/verify', [
|
||||
|
||||
if (!event) {
|
||||
// Perform a dummy bcrypt compare to prevent timing-based slug enumeration
|
||||
await bcrypt.compare(password || '', '$2b$10$abcdefghijklmnopqrstuuABCDEFGHIJKLMNOPQRSTUVWXYZ01234');
|
||||
await bcrypt.compare(password || '', DUMMY_BCRYPT_HASH);
|
||||
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
|
||||
return res.status(401).json({ error: 'Invalid gallery or password' });
|
||||
}
|
||||
@@ -421,12 +458,12 @@ router.post('/gallery/verify', [
|
||||
|
||||
// Client access login (PIN-based)
|
||||
router.post('/gallery/:slug/client-login', [
|
||||
body('password').notEmpty().isString()
|
||||
body('password').notEmpty().isString().isLength({ max: MAX_PASSWORD_LENGTH })
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
return res.status(400).json({ errors: safeValidationErrors(errors) });
|
||||
}
|
||||
|
||||
const { slug } = req.params;
|
||||
@@ -502,7 +539,7 @@ router.post('/gallery/share-login', [
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
return res.status(400).json({ errors: safeValidationErrors(errors) });
|
||||
}
|
||||
|
||||
const { slug, token } = req.body;
|
||||
@@ -760,7 +797,7 @@ router.post('/admin/change-password', [
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
return res.status(400).json({ errors: safeValidationErrors(errors) });
|
||||
}
|
||||
|
||||
const { currentPassword, newPassword } = req.body;
|
||||
@@ -831,11 +868,27 @@ router.post('/admin/change-password', [
|
||||
});
|
||||
|
||||
// Password strength check endpoint (for real-time validation)
|
||||
//
|
||||
// Unauthenticated, and it feeds the request body straight into zxcvbn, whose
|
||||
// matching is superlinear and synchronous. Without the length bound a single
|
||||
// request stops the event loop for the whole process -- ~5s at 1,000
|
||||
// characters and unbounded past that. validatePassword() enforces the same cap
|
||||
// for every caller; this one keeps the oversized body from being accepted at
|
||||
// the edge at all.
|
||||
router.post('/password-strength', [
|
||||
body('password').notEmpty(),
|
||||
body('password').isString().isLength({ min: 1, max: MAX_PASSWORD_LENGTH })
|
||||
.withMessage(`Password must be 1-${MAX_PASSWORD_LENGTH} characters`),
|
||||
body('context').isIn(['admin', 'gallery']).optional()
|
||||
], async (req, res) => {
|
||||
try {
|
||||
// The validators above only RECORD errors; without this the oversized body
|
||||
// reached zxcvbn anyway and the endpoint answered 200, so the edge cap was
|
||||
// decorative. The cap in validatePassword() is still the real control.
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: safeValidationErrors(errors) });
|
||||
}
|
||||
|
||||
const { password, context = 'gallery' } = req.body;
|
||||
|
||||
// Get user data if available (for context-aware validation)
|
||||
@@ -845,7 +898,9 @@ router.post('/password-strength', [
|
||||
userData.email = req.admin.email;
|
||||
}
|
||||
|
||||
const validation = validatePasswordInContext(password, context, userData);
|
||||
// validatePasswordInContext is async; unawaited this resolved to a Promise
|
||||
// and every field below came back undefined.
|
||||
const validation = await validatePasswordInContext(password, context, userData);
|
||||
|
||||
res.json({
|
||||
valid: validation.valid,
|
||||
|
||||
@@ -17,9 +17,10 @@ const jwt = require('jsonwebtoken');
|
||||
const { body, param, validationResult } = require('express-validator');
|
||||
const { db, logActivity } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { getBcryptRounds } = require('../utils/passwordValidation');
|
||||
const { getBcryptRounds, MAX_PASSWORD_LENGTH } = require('../utils/passwordValidation');
|
||||
const { assertContractPdfPath } = require('../utils/safePath');
|
||||
const logger = require('../utils/logger');
|
||||
const { errorResponse } = require('../utils/routeHelpers');
|
||||
const { errorResponse, safeValidationErrors } = require('../utils/routeHelpers');
|
||||
const { getClientIp } = require('../utils/requestIp');
|
||||
const { customerAuth } = require('../middleware/customerAuth');
|
||||
const { setGalleryAuthCookies } = require('../utils/tokenUtils');
|
||||
@@ -147,7 +148,7 @@ router.get('/events/:slug/access-token', [
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
return res.status(400).json({ errors: safeValidationErrors(errors) });
|
||||
}
|
||||
|
||||
const { slug } = req.params;
|
||||
@@ -284,7 +285,7 @@ router.put('/profile', [
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
return res.status(400).json({ errors: safeValidationErrors(errors) });
|
||||
}
|
||||
|
||||
// Normalise incoming values: trim strings, drop empty → null so the DB
|
||||
@@ -329,14 +330,14 @@ router.put('/profile', [
|
||||
*/
|
||||
router.post('/profile/password', [
|
||||
customerAuth,
|
||||
body('currentPassword').isString().isLength({ min: 1 }),
|
||||
body('newPassword').isString().isLength({ min: 8 })
|
||||
body('currentPassword').isString().isLength({ min: 1, max: MAX_PASSWORD_LENGTH }),
|
||||
body('newPassword').isString().isLength({ min: 8, max: MAX_PASSWORD_LENGTH })
|
||||
.withMessage('Password must be at least 8 characters'),
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
return res.status(400).json({ errors: safeValidationErrors(errors) });
|
||||
}
|
||||
|
||||
const { currentPassword, newPassword } = req.body;
|
||||
@@ -706,9 +707,13 @@ router.get('/contracts/:id/pdf', customerAuth, async (req, res) => {
|
||||
res.set('Content-Disposition', `inline; filename="${contract.contract_number}.pdf"`);
|
||||
return res.send(buf);
|
||||
}
|
||||
// Same containment the admin and public contract routes apply: the DB
|
||||
// path is written by the service layer today, but a bad row must not
|
||||
// turn this into an arbitrary-file read.
|
||||
const safePath = assertContractPdfPath(filePath);
|
||||
res.set('Content-Type', 'application/pdf');
|
||||
res.set('Content-Disposition', `inline; filename="${path.basename(filePath)}"`);
|
||||
fs.createReadStream(filePath).pipe(res);
|
||||
res.set('Content-Disposition', `inline; filename="${path.basename(safePath)}"`);
|
||||
fs.createReadStream(safePath).pipe(res);
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to render contract PDF');
|
||||
}
|
||||
|
||||
@@ -16,6 +16,9 @@ const express = require('express');
|
||||
const bcrypt = require('bcrypt');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { body, param, validationResult } = require('express-validator');
|
||||
const { safeValidationErrors } = require('../utils/routeHelpers');
|
||||
const { MAX_PASSWORD_LENGTH } = require('../utils/passwordValidation');
|
||||
const DUMMY_BCRYPT_HASH = '$2b$10$abcdefghijklmnopqrstuuABCDEFGHIJKLMNOPQRSTUVWXYZ01234';
|
||||
const { db, logActivity } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { verifyRecaptcha } = require('../services/recaptcha');
|
||||
@@ -65,12 +68,12 @@ const TOKEN_TTL_SECONDS = 24 * 60 * 60; // mirrors admin tokens
|
||||
// gallery JWTs (instant per-gallery revocation).
|
||||
router.post('/login', [
|
||||
body('email').isEmail().normalizeEmail(IDENTITY_PRESERVING_NORMALIZE_EMAIL).withMessage('Valid email is required'),
|
||||
body('password').isString().notEmpty(),
|
||||
body('password').isString().notEmpty().isLength({ max: MAX_PASSWORD_LENGTH }),
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
return res.status(400).json({ errors: safeValidationErrors(errors) });
|
||||
}
|
||||
|
||||
const { email, password, recaptchaToken } = req.body;
|
||||
@@ -99,7 +102,12 @@ router.post('/login', [
|
||||
|
||||
const customer = await db('customer_accounts').where('email', email).first();
|
||||
// Generic error to prevent user enumeration — same wording as admin login.
|
||||
if (!customer || !customer.password_hash || !await bcrypt.compare(password, customer.password_hash)) {
|
||||
// One bcrypt compare on every path so an unknown email is not a timing
|
||||
// oracle (the dummy hash matches nothing).
|
||||
const passwordMatches = customer && customer.password_hash
|
||||
? await bcrypt.compare(password, customer.password_hash)
|
||||
: await bcrypt.compare(password, DUMMY_BCRYPT_HASH).then(() => false);
|
||||
if (!passwordMatches) {
|
||||
await trackFailedAttempt(lockoutKey, ipAddress, userAgent);
|
||||
return res.status(401).json({ error: getGenericAuthError() });
|
||||
}
|
||||
@@ -273,7 +281,7 @@ router.post('/accept-invite', [
|
||||
// Length floor enforced again here for an early reject; the full
|
||||
// policy (uppercase + digit) is checked below so we can surface a
|
||||
// specific message rather than a generic validator error.
|
||||
body('password').isString().isLength({ min: 8 })
|
||||
body('password').isString().isLength({ min: 8, max: MAX_PASSWORD_LENGTH })
|
||||
.withMessage('Password must be at least 8 characters'),
|
||||
// Optional structured profile from the accept-invite form. Mirrors
|
||||
// the admin prefill shape — anything the customer types here wins
|
||||
@@ -296,7 +304,7 @@ router.post('/accept-invite', [
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
return res.status(400).json({ errors: safeValidationErrors(errors) });
|
||||
}
|
||||
const { token, name, password, profile } = req.body;
|
||||
|
||||
@@ -355,11 +363,11 @@ router.get('/password-reset/:token', [
|
||||
*/
|
||||
router.post('/password-reset', [
|
||||
body('token').isLength({ min: 64, max: 64 }).matches(/^[a-f0-9]+$/i),
|
||||
body('password').isString().isLength({ min: 8 }).withMessage('Password must be at least 8 characters'),
|
||||
body('password').isString().isLength({ min: 8, max: MAX_PASSWORD_LENGTH }).withMessage('Password must be at least 8 characters'),
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() });
|
||||
if (!errors.isEmpty()) return res.status(400).json({ errors: safeValidationErrors(errors) });
|
||||
const policyError = validateCustomerPassword(req.body.password);
|
||||
if (policyError) {
|
||||
return res.status(400).json({
|
||||
|
||||
+389
-49
@@ -10,6 +10,8 @@ const { parseBooleanInput } = require('../utils/parsers');
|
||||
const { getAppSetting } = require('../utils/appSettings');
|
||||
const archiver = require('archiver');
|
||||
const path = require('path');
|
||||
const { resolvePhotoContentType } = require('../utils/photoContentType');
|
||||
const { timingSafeEqualStr } = require('../utils/timingSafe');
|
||||
const router = express.Router();
|
||||
|
||||
// #756: a NULL per-event hero_logo_visible means "inherit the global
|
||||
@@ -24,13 +26,13 @@ function resolveHeroLogoVisible(perEvent, globalDefault) {
|
||||
}
|
||||
const watermarkService = require('../services/watermarkService');
|
||||
const watermarkGeneratorService = require('../services/watermarkGeneratorService');
|
||||
const { verifyGalleryAccess, denySlideshowToken, isAdminPreview } = require('../middleware/gallery');
|
||||
const { verifyGalleryAccess, denySlideshowToken, verifyAdminPreview } = require('../middleware/gallery');
|
||||
const { resolveGuest } = require('../middleware/guestAuth');
|
||||
const { generateGuestIdentifier } = require('../middleware/feedbackRateLimit');
|
||||
const secureImageService = require('../services/secureImageService');
|
||||
const logger = require('../utils/logger');
|
||||
const { pipeStreamToResponse } = require('../utils/streamResponse');
|
||||
const { resolvePhotoFilePath } = require('../services/photoResolver');
|
||||
const { resolvePhotoFilePath, resolvePhotoStorageKey } = require('../services/photoResolver');
|
||||
const { getEventShareToken, resolveShareIdentifier, buildShareLinkVariants } = require('../services/shareLinkService');
|
||||
const { handleAsync, errorResponse } = require('../utils/routeHelpers');
|
||||
const { NotFoundError } = require('../utils/errors');
|
||||
@@ -44,6 +46,7 @@ const {
|
||||
} = require('../services/downloadFilenameService');
|
||||
const { buildContentDisposition } = require('../utils/filenameSanitizer');
|
||||
const { getStorage } = require('../services/storage');
|
||||
const { createArchiveStreamGuard } = require('../utils/archiveStreamGuard');
|
||||
const { setGalleryAuthCookies } = require('../utils/tokenUtils');
|
||||
// Read globals from app_settings (the real table) — settingsService.getSetting
|
||||
// queries a non-existent `settings` table and throws.
|
||||
@@ -54,6 +57,43 @@ const fs = require('fs');
|
||||
// Get storage path from environment or default
|
||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
|
||||
/**
|
||||
* Parse a single-range `Range: bytes=` header against a known size.
|
||||
*
|
||||
* Returns null for absent, malformed, multi-range or unsatisfiable headers —
|
||||
* every one of which the caller answers with a normal 200 full body, which is
|
||||
* what a client that sent an unparseable range would get today anyway.
|
||||
* Validating matters because an unchecked parse yields NaN bounds and a 206
|
||||
* with a nonsense Content-Range, which corrupts a resumed download rather
|
||||
* than merely failing it.
|
||||
*/
|
||||
function parseByteRange(header, size) {
|
||||
if (!header || typeof header !== 'string' || !size) return null;
|
||||
const match = /^bytes=(\d*)-(\d*)$/.exec(header.trim());
|
||||
if (!match) return null;
|
||||
|
||||
const [, rawStart, rawEnd] = match;
|
||||
if (rawStart === '' && rawEnd === '') return null;
|
||||
|
||||
let start;
|
||||
let end;
|
||||
if (rawStart === '') {
|
||||
// Suffix form: the last N bytes.
|
||||
const suffix = parseInt(rawEnd, 10);
|
||||
if (!suffix) return null;
|
||||
start = Math.max(0, size - suffix);
|
||||
end = size - 1;
|
||||
} else {
|
||||
start = parseInt(rawStart, 10);
|
||||
end = rawEnd === '' ? size - 1 : parseInt(rawEnd, 10);
|
||||
}
|
||||
|
||||
if (!Number.isFinite(start) || !Number.isFinite(end)) return null;
|
||||
if (start > end || start >= size) return null;
|
||||
return { start, end: Math.min(end, size - 1) };
|
||||
}
|
||||
|
||||
|
||||
// Check for slug redirect (for renamed events)
|
||||
async function checkSlugRedirect(slug) {
|
||||
try {
|
||||
@@ -71,11 +111,36 @@ async function checkSlugRedirect(slug) {
|
||||
}
|
||||
}
|
||||
|
||||
// Admin preview of an unpublished gallery (#1386). The /info route below has
|
||||
// honoured ?preview= for drafts for a while; this route never did, so the
|
||||
// short-URL form of a draft's share link 404'd with "Gallery Not Found" while
|
||||
// the long slug form worked.
|
||||
//
|
||||
// Deliberately a second lookup on the miss path rather than a widened filter:
|
||||
// the published case keeps its single query and cannot start returning drafts
|
||||
// however this evolves, and an unverified caller never gets so far as knowing
|
||||
// the draft exists.
|
||||
async function resolveDraftForAdminPreview(req, identifier) {
|
||||
// A preview credential is required either way, so checking up front costs
|
||||
// nothing and keeps an unknown identifier from paying for a second set of
|
||||
// lookups on the public 404 path. admin_preview=1 is what the frontend
|
||||
// sends; the bare ?preview=<jwt> is the legacy hand-built-link form.
|
||||
if (req.query?.admin_preview !== '1' && !req.query?.preview) return null;
|
||||
const result = await resolveShareIdentifier(identifier, { includeDrafts: true });
|
||||
if (!result) return null;
|
||||
// Authorized against THIS event, not just against a valid signature (#1411).
|
||||
return await verifyAdminPreview(req, result.event) ? result : null;
|
||||
}
|
||||
|
||||
// Resolve gallery identifier (slug or token) to canonical data
|
||||
router.get('/resolve/:identifier', handleAsync(async (req, res) => {
|
||||
const { identifier } = req.params;
|
||||
let result = await resolveShareIdentifier(identifier);
|
||||
|
||||
if (!result) {
|
||||
result = await resolveDraftForAdminPreview(req, identifier);
|
||||
}
|
||||
|
||||
// If not found, check for redirect
|
||||
if (!result) {
|
||||
const newSlug = await checkSlugRedirect(identifier);
|
||||
@@ -121,16 +186,26 @@ router.get('/:slug/verify-token/:token', handleAsync(async (req, res) => {
|
||||
const { slug, token } = req.params;
|
||||
|
||||
const event = await db('events')
|
||||
.where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false), is_draft: formatBoolean(false) })
|
||||
.select('id', 'share_link', 'share_token')
|
||||
.where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) })
|
||||
// created_by is the ownership input for verifyAdminPreview (#1411).
|
||||
// Omitting it made every draft look ownerless here, so a non-owning admin
|
||||
// holding events.view/photos.view validated another photographer's share
|
||||
// link while /resolve and /info correctly refused them.
|
||||
.select('id', 'share_link', 'share_token', 'is_draft', 'created_by')
|
||||
.first();
|
||||
|
||||
if (!event) {
|
||||
throw new NotFoundError('Gallery');
|
||||
}
|
||||
|
||||
// Drafts are visible to an authorized admin preview only (#1386, #1411).
|
||||
// Without this the preview clears /resolve and then 404s one step later.
|
||||
if (event.is_draft && !await verifyAdminPreview(req, event)) {
|
||||
throw new NotFoundError('Gallery');
|
||||
}
|
||||
|
||||
const expectedToken = getEventShareToken(event);
|
||||
if (token !== expectedToken) {
|
||||
if (!expectedToken || !timingSafeEqualStr(String(token), expectedToken)) {
|
||||
throw new NotFoundError('Gallery', 'Invalid gallery link');
|
||||
}
|
||||
|
||||
@@ -171,6 +246,8 @@ router.get('/:slug/info', async (req, res) => {
|
||||
'hero_divider_style',
|
||||
'hero_image_anchor',
|
||||
'is_draft',
|
||||
// Ownership input for the preview check (#1411).
|
||||
'created_by',
|
||||
'default_photo_sort',
|
||||
// Per-event promotional override (#440). Resolution into a
|
||||
// ready-to-render markdown string happens below so the
|
||||
@@ -198,15 +275,17 @@ router.get('/:slug/info', async (req, res) => {
|
||||
return res.status(404).json({ error: 'Gallery has been archived and is no longer available' });
|
||||
}
|
||||
|
||||
// Check if event is a draft (allow admin preview)
|
||||
if (event.is_draft && !isAdminPreview(req)) {
|
||||
// Check if event is a draft (allow an AUTHORIZED admin preview — #1411:
|
||||
// a valid signature alone used to be enough, so any admin previewed any
|
||||
// draft, including one belonging to a different photographer).
|
||||
if (event.is_draft && !await verifyAdminPreview(req, event)) {
|
||||
return res.status(404).json({ error: 'Gallery is not yet published' });
|
||||
}
|
||||
|
||||
// If token provided, verify it matches the share link
|
||||
if (token) {
|
||||
const expectedToken = getEventShareToken(event);
|
||||
if (!expectedToken || token !== expectedToken) {
|
||||
if (!expectedToken || !timingSafeEqualStr(String(token), expectedToken)) {
|
||||
return res.status(404).json({ error: 'Invalid gallery link' });
|
||||
}
|
||||
}
|
||||
@@ -810,7 +889,17 @@ router.get('/:slug/photos', verifyGalleryAccess, resolveGuest, async (req, res)
|
||||
},
|
||||
categories: categories,
|
||||
photos: photos.map(photo => {
|
||||
const useJwtUrl = (protectionSettings.protection_level === 'basic' || protectionSettings.protection_level === 'standard');
|
||||
// Videos always take the JWT route (#1370). The secure-images template
|
||||
// below can never serve one — the route runs the bytes through sharp,
|
||||
// which throws on an mp4 — and nothing substitutes the {{token}}
|
||||
// placeholder for the <video> element either, so under enhanced/maximum
|
||||
// a video resolved to a 403 and the lightbox sat at 0:00. The matching
|
||||
// exemption is on the /photo/:photoId route below.
|
||||
const isVideo = photo.media_type === 'video'
|
||||
|| (photo.mime_type && photo.mime_type.startsWith('video/'));
|
||||
const useJwtUrl = isVideo
|
||||
|| protectionSettings.protection_level === 'basic'
|
||||
|| protectionSettings.protection_level === 'standard';
|
||||
// Add watermark version to URLs for cache busting when settings change
|
||||
const wmQuery = wmVersion ? `?${wmVersion}` : '';
|
||||
const photoUrl = useJwtUrl ?
|
||||
@@ -996,6 +1085,47 @@ router.get('/:slug/download/:photoId', verifyGalleryAccess, denySlideshowToken,
|
||||
}
|
||||
}
|
||||
|
||||
// A HEAD is a metadata probe, not a download. Answering it below the
|
||||
// counters recorded every probe as a real download, and answering it below
|
||||
// renderPhotoForDownload fetched and watermarked an image whose body Node
|
||||
// then discards. Both happen before this point in a GET, so HEAD leaves
|
||||
// here — with no side effects and no bytes read.
|
||||
if (req.method === 'HEAD') {
|
||||
const headUseOriginal = await getUseOriginalFilenames();
|
||||
const headHeaders = {
|
||||
'Content-Type': resolvePhotoContentType(photo),
|
||||
'Content-Disposition': buildContentDisposition(pickRawDownloadName(photo, headUseOriginal)),
|
||||
'Accept-Ranges': 'bytes',
|
||||
};
|
||||
|
||||
// Content-Length only when the bytes ship untransformed AND the size can
|
||||
// be read without fetching them. A watermark or resize changes the
|
||||
// length, and the only way to learn the new one is to do the work this
|
||||
// branch exists to avoid — HEAD is allowed to omit it.
|
||||
const headWmSettings = await watermarkService.getWatermarkSettings();
|
||||
const headWmEnabled = !!(headWmSettings && headWmSettings.enabled)
|
||||
|| req.event.watermark_downloads === true
|
||||
|| req.event.watermark_downloads === 1;
|
||||
if (!headWmEnabled) {
|
||||
try {
|
||||
const headKey = resolvePhotoStorageKey(req.event, photo);
|
||||
const headStorage = getStorage();
|
||||
if (headKey && headStorage.kind() !== 'local') {
|
||||
const headStat = await headStorage.stat(headKey);
|
||||
if (!headStat) return res.status(404).json({ error: 'Photo file not found' });
|
||||
headHeaders['Content-Length'] = headStat.size;
|
||||
if (headStat.mtime) headHeaders['Last-Modified'] = new Date(headStat.mtime).toUTCString();
|
||||
}
|
||||
} catch (headErr) {
|
||||
// No length is a valid HEAD; not worth failing the probe over.
|
||||
logger.debug('HEAD probe could not stat the object', { photoId, error: headErr.message });
|
||||
}
|
||||
}
|
||||
|
||||
res.set(headHeaders);
|
||||
return res.end();
|
||||
}
|
||||
|
||||
// Update download count
|
||||
await db('photos').where('id', photoId).increment('download_count', 1);
|
||||
|
||||
@@ -1008,11 +1138,19 @@ router.get('/:slug/download/:photoId', verifyGalleryAccess, denySlideshowToken,
|
||||
photo_id: photoId
|
||||
});
|
||||
|
||||
let filePath;
|
||||
// Where the bytes actually live. Managed photos sit behind the storage
|
||||
// abstraction and on an S3/R2 deployment are not on local disk at all —
|
||||
// resolving a filesystem path unconditionally here is what made every
|
||||
// single-photo download fail in S3 mode, while download-all and
|
||||
// secure-images worked because they already went through getStorage().
|
||||
//
|
||||
// resolvePhotoStorageKey returns null for external/reference photos: those
|
||||
// live on a local mount and keep the filesystem path.
|
||||
let storageKey = null;
|
||||
try {
|
||||
filePath = resolvePhotoFilePath(req.event, photo);
|
||||
storageKey = resolvePhotoStorageKey(req.event, photo);
|
||||
} catch (resolveError) {
|
||||
logger.error('Failed to resolve photo path for download', {
|
||||
logger.error('Failed to resolve photo storage key for download', {
|
||||
slug: req.params.slug,
|
||||
photoId,
|
||||
eventId: req.event.id,
|
||||
@@ -1020,7 +1158,7 @@ router.get('/:slug/download/:photoId', verifyGalleryAccess, denySlideshowToken,
|
||||
});
|
||||
return res.status(404).json({ error: 'Photo file not found' });
|
||||
}
|
||||
|
||||
|
||||
// Get watermark settings - apply if global setting OR event-level setting is enabled
|
||||
const watermarkSettings = await watermarkService.getWatermarkSettings();
|
||||
const eventWatermarkEnabled = req.event.watermark_downloads === true || req.event.watermark_downloads === 1;
|
||||
@@ -1041,35 +1179,170 @@ router.get('/:slug/download/:photoId', verifyGalleryAccess, denySlideshowToken,
|
||||
enabled: true,
|
||||
text: req.event.watermark_text || watermarkSettings?.text || 'Protected'
|
||||
};
|
||||
const watermarkedBuffer = await watermarkService.applyWatermark(filePath, effectiveSettings);
|
||||
|
||||
// applyWatermark takes a PATH, and caches on it — buffer inputs skip the
|
||||
// cache deliberately. In S3 mode materialize a tmp local copy and hand
|
||||
// it the copy's path, exactly as the zip builders below do, so the cache
|
||||
// still applies and the full-size original isn't re-processed per
|
||||
// download.
|
||||
let watermarkedBuffer;
|
||||
try {
|
||||
watermarkedBuffer = storageKey
|
||||
? await withLocalCopy(storageKey, (localPath) =>
|
||||
watermarkService.applyWatermark(localPath, effectiveSettings))
|
||||
: await watermarkService.applyWatermark(
|
||||
resolvePhotoFilePath(req.event, photo), effectiveSettings);
|
||||
} catch (watermarkError) {
|
||||
// Classify, the same way the pass-through branch below does. This can
|
||||
// fail because the source object is gone, but equally because
|
||||
// getToFile timed out, the tmp filesystem filled up, or sharp failed —
|
||||
// and reporting an operational failure as 404 tells the guest their
|
||||
// photo does not exist and tells us nothing.
|
||||
const gone = watermarkError.code === 'ENOENT'
|
||||
|| watermarkError.name === 'NoSuchKey'
|
||||
|| watermarkError.name === 'NotFound'
|
||||
|| watermarkError.$metadata?.httpStatusCode === 404;
|
||||
logger.error('Failed to watermark photo for download', {
|
||||
slug: req.params.slug,
|
||||
photoId,
|
||||
eventId: req.event.id,
|
||||
error: watermarkError.message,
|
||||
});
|
||||
return gone
|
||||
? res.status(404).json({ error: 'Photo file not found' })
|
||||
: res.status(500).json({ error: 'Failed to download photo' });
|
||||
}
|
||||
|
||||
res.set({
|
||||
'Content-Type': photo.mime_type || 'image/jpeg',
|
||||
'Content-Type': resolvePhotoContentType(photo),
|
||||
'Content-Disposition': contentDisposition,
|
||||
'Content-Length': watermarkedBuffer.length
|
||||
});
|
||||
|
||||
res.send(watermarkedBuffer);
|
||||
} else {
|
||||
// res.download() builds Content-Disposition itself but doesn't emit the
|
||||
// RFC 5987 filename* parameter, so unicode camera filenames would lose
|
||||
// their bytes on download. Set the header explicitly and stream the
|
||||
// file with res.sendFile-equivalent semantics.
|
||||
res.set({
|
||||
'Content-Type': photo.mime_type || 'image/jpeg',
|
||||
'Content-Disposition': contentDisposition,
|
||||
});
|
||||
res.sendFile(filePath, (downloadError) => {
|
||||
if (downloadError) {
|
||||
logger.error('Error streaming gallery download', {
|
||||
slug: req.params.slug,
|
||||
photoId,
|
||||
eventId: req.event.id,
|
||||
error: downloadError.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
return res.send(watermarkedBuffer);
|
||||
}
|
||||
|
||||
const storage = getStorage();
|
||||
if (storageKey && storage.kind() !== 'local') {
|
||||
// Deliberately NOT the local path: res.sendFile emits Content-Length,
|
||||
// Accept-Ranges, ETag and Last-Modified and answers Range requests with
|
||||
// a 206, and a bare stream.pipe(res) has none of that. On local disk
|
||||
// sendFile stays the better implementation, so it stays the branch.
|
||||
//
|
||||
// On S3 the parts that matter for a download are reproduced: the length
|
||||
// (browsers need it for the progress indicator, which matters most on
|
||||
// exactly the large files this route serves) and Range, so an
|
||||
// interrupted download resumes instead of appending a second full body
|
||||
// onto the partial file.
|
||||
const stat = await storage.stat(storageKey);
|
||||
if (!stat) {
|
||||
logger.error('Photo not found in storage backend for download', {
|
||||
slug: req.params.slug,
|
||||
photoId,
|
||||
eventId: req.event.id,
|
||||
storageKey,
|
||||
});
|
||||
return res.status(404).json({ error: 'Photo file not found' });
|
||||
}
|
||||
|
||||
const lastModified = stat.mtime ? new Date(stat.mtime).toUTCString() : null;
|
||||
const headers = {
|
||||
'Content-Type': resolvePhotoContentType(photo),
|
||||
'Content-Disposition': contentDisposition,
|
||||
'Accept-Ranges': 'bytes',
|
||||
};
|
||||
if (lastModified) headers['Last-Modified'] = lastModified;
|
||||
|
||||
// If-Range: a client resuming an interrupted download sends back the
|
||||
// validator it was given last time. If the object has been replaced
|
||||
// since — the watcher re-importing a swapped file, an admin re-upload —
|
||||
// answering 206 from the NEW bytes lets the client splice two different
|
||||
// versions into one corrupt file. A validator that doesn't match means
|
||||
// a full 200, which is the whole point of the header.
|
||||
const ifRange = req.headers['if-range'];
|
||||
const staleValidator = !!ifRange && (!lastModified || ifRange.trim() !== lastModified);
|
||||
const range = staleValidator ? null : parseByteRange(req.headers.range, stat.size);
|
||||
|
||||
// Open the stream BEFORE any header is staged or sent. stat() succeeding
|
||||
// does not mean get() will: a concurrent delete or replace, or a
|
||||
// transient backend error, lands here. Once writeHead(206) has gone out
|
||||
// the outer catch can do nothing but throw ERR_HTTP_HEADERS_SENT, and in
|
||||
// the non-range case it would send its 500 JSON underneath the staged
|
||||
// image/jpeg attachment headers — a .jpg file full of JSON.
|
||||
let stream;
|
||||
try {
|
||||
stream = range
|
||||
? await storage.getRange(storageKey, range.start, range.end)
|
||||
: await storage.get(storageKey);
|
||||
} catch (fetchError) {
|
||||
const gone = fetchError.code === 'ENOENT'
|
||||
|| fetchError.name === 'NoSuchKey'
|
||||
|| fetchError.name === 'NotFound'
|
||||
|| fetchError.$metadata?.httpStatusCode === 404;
|
||||
logger.error('Failed to open photo stream for download', {
|
||||
slug: req.params.slug,
|
||||
photoId,
|
||||
eventId: req.event.id,
|
||||
storageKey,
|
||||
error: fetchError.message,
|
||||
});
|
||||
return gone
|
||||
? res.status(404).json({ error: 'Photo file not found' })
|
||||
: res.status(500).json({ error: 'Failed to download photo' });
|
||||
}
|
||||
|
||||
if (range) {
|
||||
// status()+set() rather than writeHead(): writeHead commits the
|
||||
// response immediately, so a stream that resolves and THEN errors
|
||||
// before its first chunk would leave pipeStreamToResponse able only to
|
||||
// destroy the connection. Staged headers are flushed by the first body
|
||||
// write, which means an error at byte zero can still clear them and
|
||||
// return a clean, retryable status instead of a transport reset.
|
||||
res.status(206).set({
|
||||
...headers,
|
||||
'Content-Range': `bytes ${range.start}-${range.end}/${stat.size}`,
|
||||
'Content-Length': (range.end - range.start) + 1,
|
||||
});
|
||||
} else {
|
||||
res.set({ ...headers, 'Content-Length': stat.size });
|
||||
}
|
||||
pipeStreamToResponse(stream, res, {
|
||||
context: range ? `download range for photo ${photo.id}` : `download for photo ${photo.id}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let filePath;
|
||||
try {
|
||||
filePath = resolvePhotoFilePath(req.event, photo);
|
||||
} catch (resolveError) {
|
||||
logger.error('Failed to resolve photo path for download', {
|
||||
slug: req.params.slug,
|
||||
photoId,
|
||||
eventId: req.event.id,
|
||||
error: resolveError.message,
|
||||
});
|
||||
return res.status(404).json({ error: 'Photo file not found' });
|
||||
}
|
||||
|
||||
// res.download() builds Content-Disposition itself but doesn't emit the
|
||||
// RFC 5987 filename* parameter, so unicode camera filenames would lose
|
||||
// their bytes on download. Set the header explicitly and stream the
|
||||
// file with res.sendFile-equivalent semantics.
|
||||
res.set({
|
||||
'Content-Type': resolvePhotoContentType(photo),
|
||||
'Content-Disposition': contentDisposition,
|
||||
});
|
||||
res.sendFile(filePath, (downloadError) => {
|
||||
if (downloadError) {
|
||||
logger.error('Error streaming gallery download', {
|
||||
slug: req.params.slug,
|
||||
photoId,
|
||||
eventId: req.event.id,
|
||||
error: downloadError.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to download photo');
|
||||
}
|
||||
@@ -1093,6 +1366,10 @@ async function bumpEventDownloadCounts(eventId) {
|
||||
}
|
||||
|
||||
router.get('/:slug/download-all', verifyGalleryAccess, denySlideshowToken, async (req, res) => {
|
||||
// Hoisted so the catch can reclaim reads opened before the failure, and so a
|
||||
// cancelled download never reaches finalize() (see the close handler below).
|
||||
let guard = null;
|
||||
let cancelled = false;
|
||||
try {
|
||||
// Check if downloads are allowed for this event
|
||||
if (!parseBooleanInput(req.event.allow_downloads, true)) {
|
||||
@@ -1207,6 +1484,22 @@ router.get('/:slug/download-all', verifyGalleryAccess, denySlideshowToken, async
|
||||
throw err;
|
||||
});
|
||||
|
||||
// Bound and reclaim the storage reads. archiver drains its sources one at
|
||||
// a time, so appending one read per photo parks an S3 socket per photo
|
||||
// holding unread bytes, and nothing reclaims them — archiver's abort()
|
||||
// does not touch source streams, and the SDK clears its socket timeout as
|
||||
// soon as response headers land.
|
||||
guard = createArchiveStreamGuard({
|
||||
onFatalError: () => { cancelled = true; guard.destroyAll(); archive.abort(); },
|
||||
});
|
||||
res.on('close', () => {
|
||||
if (!res.writableFinished) {
|
||||
cancelled = true;
|
||||
guard.destroyAll();
|
||||
archive.abort();
|
||||
}
|
||||
});
|
||||
|
||||
archive.pipe(res);
|
||||
|
||||
// Get watermark settings - apply if global setting OR event-level setting is enabled
|
||||
@@ -1275,8 +1568,9 @@ router.get('/:slug/download-all', verifyGalleryAccess, denySlideshowToken, async
|
||||
|
||||
archive.append(watermarkedBuffer, { name: archiveName });
|
||||
} else if (storageKey) {
|
||||
if (!await guard.acquire()) break;
|
||||
const stream = await storage.get(storageKey);
|
||||
archive.append(stream, { name: archiveName });
|
||||
archive.append(guard.track(stream), { name: archiveName });
|
||||
} else {
|
||||
archive.file(resolvePhotoFilePath(req.event, photo), { name: archiveName });
|
||||
}
|
||||
@@ -1291,6 +1585,7 @@ router.get('/:slug/download-all', verifyGalleryAccess, denySlideshowToken, async
|
||||
}
|
||||
}
|
||||
|
||||
if (cancelled) return;
|
||||
await archive.finalize();
|
||||
|
||||
// Log bulk download
|
||||
@@ -1307,12 +1602,19 @@ router.get('/:slug/download-all', verifyGalleryAccess, denySlideshowToken, async
|
||||
.increment('download_count', 1).catch(() => {});
|
||||
}
|
||||
} catch (error) {
|
||||
if (guard) guard.destroyAll();
|
||||
// The client already left and the ZIP headers are gone; sending JSON here
|
||||
// throws ERR_HTTP_HEADERS_SENT out of an async handler with nothing to
|
||||
// catch it.
|
||||
if (cancelled || res.headersSent) return;
|
||||
errorResponse(res, error, 500, 'Failed to create download archive');
|
||||
}
|
||||
});
|
||||
|
||||
// Download selected photos as ZIP
|
||||
router.post('/:slug/download-selected', verifyGalleryAccess, denySlideshowToken, async (req, res) => {
|
||||
let selectedGuard = null;
|
||||
let selectedCancelled = false;
|
||||
try {
|
||||
// Check if downloads are allowed for this event
|
||||
if (!parseBooleanInput(req.event.allow_downloads, true)) {
|
||||
@@ -1372,6 +1674,19 @@ router.post('/:slug/download-selected', verifyGalleryAccess, denySlideshowToken,
|
||||
// ignore double-send errors
|
||||
}
|
||||
});
|
||||
|
||||
selectedGuard = createArchiveStreamGuard({
|
||||
onFatalError: () => { selectedCancelled = true; selectedGuard.destroyAll(); archive.abort(); },
|
||||
});
|
||||
archive.on('error', () => selectedGuard.destroyAll());
|
||||
res.on('close', () => {
|
||||
if (!res.writableFinished) {
|
||||
selectedCancelled = true;
|
||||
selectedGuard.destroyAll();
|
||||
archive.abort();
|
||||
}
|
||||
});
|
||||
|
||||
archive.pipe(res);
|
||||
|
||||
// Check watermark settings - apply if global setting OR event-level setting is enabled
|
||||
@@ -1418,8 +1733,9 @@ router.post('/:slug/download-selected', verifyGalleryAccess, denySlideshowToken,
|
||||
: await watermarkService.applyWatermark(resolvePhotoFilePath(req.event, photo), effectiveSettings);
|
||||
archive.append(buf, { name });
|
||||
} else if (storageKey) {
|
||||
if (!await selectedGuard.acquire()) break;
|
||||
const stream = await selectedStorage.get(storageKey);
|
||||
archive.append(stream, { name });
|
||||
archive.append(selectedGuard.track(stream), { name });
|
||||
} else {
|
||||
archive.file(resolvePhotoFilePath(req.event, photo), { name });
|
||||
}
|
||||
@@ -1434,6 +1750,7 @@ router.post('/:slug/download-selected', verifyGalleryAccess, denySlideshowToken,
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedCancelled) return;
|
||||
await archive.finalize();
|
||||
|
||||
await db('access_logs').insert({
|
||||
@@ -1449,6 +1766,8 @@ router.post('/:slug/download-selected', verifyGalleryAccess, denySlideshowToken,
|
||||
.increment('download_count', 1).catch(() => {});
|
||||
}
|
||||
} catch (error) {
|
||||
if (selectedGuard) selectedGuard.destroyAll();
|
||||
if (selectedCancelled || res.headersSent) return;
|
||||
errorResponse(res, error, 500, 'Failed to download selected photos');
|
||||
}
|
||||
});
|
||||
@@ -1511,7 +1830,14 @@ router.get('/:slug/photo/:photoId',
|
||||
// Check protection level - basic and standard protection allow direct JWT access
|
||||
const protectionLevel = req.event.protection_level || 'standard';
|
||||
|
||||
if (protectionLevel === 'enhanced' || protectionLevel === 'maximum') {
|
||||
// Videos are exempt (#1370). The secure-images endpoint this bounces to
|
||||
// pipes every byte through sharp (secureImageService.processProtectedImage),
|
||||
// which throws on an mp4 — so under enhanced/maximum a video was
|
||||
// unservable by either route, and the lightbox showed a poster stuck at
|
||||
// 0:00. Serving it here instead is not a new exposure: thumbnails of the
|
||||
// same videos already come from this route at every protection level, and
|
||||
// the guest still needs a valid gallery token to get here at all.
|
||||
if (!isVideo && (protectionLevel === 'enhanced' || protectionLevel === 'maximum')) {
|
||||
// For enhanced/maximum protection, redirect to secure endpoint
|
||||
return res.status(302).json({
|
||||
error: 'Secure access required',
|
||||
@@ -1596,25 +1922,34 @@ router.get('/:slug/photo/:photoId',
|
||||
const parts = range.replace(/bytes=/, '').split('-');
|
||||
const start = parseInt(parts[0], 10);
|
||||
const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;
|
||||
const chunksize = (end - start) + 1;
|
||||
// Validate before writing the 206: a NaN, inverted or out-of-file
|
||||
// range used to be committed to the headers and then throw while
|
||||
// streaming (or read past the end).
|
||||
if (!Number.isInteger(start) || !Number.isInteger(end)
|
||||
|| start < 0 || end < start || start >= fileSize) {
|
||||
res.set('Content-Range', `bytes */${fileSize}`);
|
||||
return res.status(416).end();
|
||||
}
|
||||
const boundedEnd = Math.min(end, fileSize - 1);
|
||||
const chunksize = (boundedEnd - start) + 1;
|
||||
|
||||
res.writeHead(206, {
|
||||
'Content-Range': `bytes ${start}-${end}/${fileSize}`,
|
||||
'Content-Range': `bytes ${start}-${boundedEnd}/${fileSize}`,
|
||||
'Accept-Ranges': 'bytes',
|
||||
'Content-Length': chunksize,
|
||||
'Content-Type': photo.mime_type || 'video/mp4',
|
||||
'Content-Type': resolvePhotoContentType(photo),
|
||||
'Cache-Control': 'private, max-age=1800',
|
||||
'X-Protection-Level': 'basic'
|
||||
});
|
||||
|
||||
const file = useStorageBackend
|
||||
? await storage.getRange(storageKey, start, end)
|
||||
: fs.createReadStream(filePath, { start, end });
|
||||
? await storage.getRange(storageKey, start, boundedEnd)
|
||||
: fs.createReadStream(filePath, { start, end: boundedEnd });
|
||||
pipeStreamToResponse(file, res, { context: `video range for photo ${photo.id}` });
|
||||
} else {
|
||||
res.writeHead(200, {
|
||||
'Content-Length': fileSize,
|
||||
'Content-Type': photo.mime_type || 'video/mp4',
|
||||
'Content-Type': resolvePhotoContentType(photo),
|
||||
'Accept-Ranges': 'bytes',
|
||||
'Cache-Control': 'private, max-age=1800',
|
||||
'X-Protection-Level': 'basic'
|
||||
@@ -1649,7 +1984,7 @@ router.get('/:slug/photo/:photoId',
|
||||
const wmStat = await storage.stat(photo.watermark_path);
|
||||
if (wmStat) {
|
||||
res.set({
|
||||
'Content-Type': photo.mime_type || 'image/jpeg',
|
||||
'Content-Type': resolvePhotoContentType(photo),
|
||||
'Content-Length': wmStat.size,
|
||||
'Cache-Control': 'private, max-age=1800',
|
||||
'ETag': etag,
|
||||
@@ -1662,7 +1997,7 @@ router.get('/:slug/photo/:photoId',
|
||||
const watermarkFilePath = path.join(getStoragePath(), photo.watermark_path);
|
||||
if (fs.existsSync(watermarkFilePath)) {
|
||||
res.set({
|
||||
'Content-Type': photo.mime_type || 'image/jpeg',
|
||||
'Content-Type': resolvePhotoContentType(photo),
|
||||
'Cache-Control': 'private, max-age=1800',
|
||||
'ETag': etag,
|
||||
'X-Protection-Level': 'basic'
|
||||
@@ -1688,7 +2023,7 @@ router.get('/:slug/photo/:photoId',
|
||||
.catch(err => logger.warn(`Background watermark generation failed for photo ${photo.id}:`, err.message));
|
||||
|
||||
res.set({
|
||||
'Content-Type': photo.mime_type || 'image/jpeg',
|
||||
'Content-Type': resolvePhotoContentType(photo),
|
||||
'Cache-Control': 'private, max-age=1800',
|
||||
'ETag': etag,
|
||||
'X-Protection-Level': 'basic'
|
||||
@@ -1703,7 +2038,7 @@ router.get('/:slug/photo/:photoId',
|
||||
});
|
||||
if (useStorageBackend) {
|
||||
res.set('Content-Length', stat.size);
|
||||
if (photo.mime_type) res.set('Content-Type', photo.mime_type);
|
||||
res.set('Content-Type', resolvePhotoContentType(photo));
|
||||
const stream = await storage.get(storageKey);
|
||||
pipeStreamToResponse(stream, res, { context: `photo ${photo.id}` });
|
||||
} else {
|
||||
@@ -2110,7 +2445,12 @@ router.post('/:eventId/upload', verifyGalleryAccess, denySlideshowToken, async (
|
||||
dest: tempUploadDir,
|
||||
limits: {
|
||||
fileSize: 50 * 1024 * 1024, // 50MB per file (separate concern from #613)
|
||||
files: maxFilesPerUpload
|
||||
files: maxFilesPerUpload,
|
||||
// CVE-2026-82333: files arrive as repeated `photos` parts via
|
||||
// .array(), not bracket-indexed field names like `photos[0]` — no
|
||||
// legitimate field name uses array-index syntax at all. Reject any
|
||||
// that do.
|
||||
fieldArrayIndexLimit: 0
|
||||
},
|
||||
fileFilter: (req, file, cb) => {
|
||||
if (validateFileType(file.originalname, file.mimetype, allowedMimeTypes)) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const express = require('express');
|
||||
const { resolvePhotoContentType } = require('../utils/photoContentType');
|
||||
const { db } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { verifyGalleryAccess } = require('../middleware/gallery');
|
||||
@@ -165,7 +166,7 @@ router.get('/:slug/photo/:photoId/view', verifyGalleryAccess, async (req, res) =
|
||||
|
||||
// Set security headers
|
||||
res.set({
|
||||
'Content-Type': photo.mime_type || 'image/jpeg',
|
||||
'Content-Type': resolvePhotoContentType(photo),
|
||||
'Content-Length': finalImage.length,
|
||||
'Cache-Control': 'private, no-cache, no-store, must-revalidate',
|
||||
'Pragma': 'no-cache',
|
||||
@@ -335,7 +336,7 @@ router.get('/:slug/photo/:photoId/signed/:token', async (req, res) => {
|
||||
|
||||
// Set appropriate headers
|
||||
res.set({
|
||||
'Content-Type': photo.mime_type || 'image/jpeg',
|
||||
'Content-Type': resolvePhotoContentType(photo),
|
||||
'Content-Length': imageBuffer.length,
|
||||
'Cache-Control': 'private, max-age=3600',
|
||||
'X-Content-Type-Options': 'nosniff'
|
||||
|
||||
@@ -62,7 +62,10 @@ const signedPdfStorage = multer.diskStorage({
|
||||
|
||||
const signedPdfUpload = multer({
|
||||
storage: signedPdfStorage,
|
||||
limits: { fileSize: 10 * 1024 * 1024 }, // 10 MB
|
||||
// CVE-2026-82333: single unnamed `file` field only, and this route is
|
||||
// unauthenticated (token-only) — no legitimate array-indexed field
|
||||
// names, so reject any bracket-index field name.
|
||||
limits: { fileSize: 10 * 1024 * 1024, fieldArrayIndexLimit: 0 }, // 10 MB
|
||||
fileFilter: (req, file, cb) => {
|
||||
if (validateFileType(file.originalname, file.mimetype, ['application/pdf'])) return cb(null, true);
|
||||
return cb(new Error('Only PDF files are allowed'));
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const express = require('express');
|
||||
const { resolvePhotoContentType } = require('../utils/photoContentType');
|
||||
const { db } = require('../database/db');
|
||||
const { verifyGalleryAccess, denySlideshowToken } = require('../middleware/gallery');
|
||||
const secureImageService = require('../services/secureImageService');
|
||||
@@ -236,7 +237,7 @@ router.get('/:slug/secure/:photoId/:token',
|
||||
|
||||
// Set content type and security headers
|
||||
res.set({
|
||||
'Content-Type': photo.mime_type || 'image/jpeg',
|
||||
'Content-Type': resolvePhotoContentType(photo),
|
||||
'Content-Length': processedImage.length,
|
||||
'X-Protection-Level': protectionSettings.protectionLevel,
|
||||
'X-Remaining-Uses': tokenValidation.remaining
|
||||
@@ -425,7 +426,7 @@ router.get('/:slug/secure-download/:photoId/:token',
|
||||
const downloadName = pickRawDownloadName(photo, useOriginal);
|
||||
|
||||
res.set({
|
||||
'Content-Type': photo.mime_type || 'image/jpeg',
|
||||
'Content-Type': resolvePhotoContentType(photo),
|
||||
'Content-Disposition': buildContentDisposition(downloadName),
|
||||
'Content-Length': fileBuffer.length,
|
||||
'X-Download-Protected': 'true'
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
// rate-limited at the mount point in server.js (authRateLimiter).
|
||||
const express = require('express');
|
||||
const { body, validationResult } = require('express-validator');
|
||||
const { safeValidationErrors } = require('../utils/routeHelpers');
|
||||
const { MAX_PASSWORD_LENGTH } = require('../utils/passwordValidation');
|
||||
const setupService = require('../services/setupService');
|
||||
const { getClientIp } = require('../utils/requestIp');
|
||||
const { setAdminAuthCookie } = require('../utils/tokenUtils');
|
||||
@@ -31,7 +33,7 @@ router.post('/verify-token', [
|
||||
], async (req, res) => {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
return res.status(400).json({ errors: safeValidationErrors(errors) });
|
||||
}
|
||||
try {
|
||||
const valid = await setupService.verifySetupToken(req.body.token);
|
||||
@@ -51,11 +53,11 @@ router.post('/verify-token', [
|
||||
router.post('/admin', [
|
||||
body('token').notEmpty().withMessage('Setup token is required'),
|
||||
body('email').isEmail().withMessage('A valid email is required'),
|
||||
body('password').notEmpty().withMessage('Password is required'),
|
||||
body('password').isString().notEmpty().isLength({ max: MAX_PASSWORD_LENGTH }).withMessage('Password is required'),
|
||||
], async (req, res) => {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
return res.status(400).json({ errors: safeValidationErrors(errors) });
|
||||
}
|
||||
try {
|
||||
const { token, email, password } = req.body;
|
||||
|
||||
@@ -18,6 +18,7 @@ const crypto = require('crypto');
|
||||
const multer = require('multer');
|
||||
const sharp = require('sharp');
|
||||
const { body, query, validationResult } = require('express-validator');
|
||||
const { safeValidationErrors } = require('../../utils/routeHelpers');
|
||||
const { db, logActivity } = require('../../database/db');
|
||||
const { apiTokenAuth, requireApiScope } = require('../../middleware/apiTokenAuth');
|
||||
const { requireEventOwnership, scopeEventsQuery } = require('../../middleware/ownership');
|
||||
@@ -56,7 +57,9 @@ const photoStorage = multer.diskStorage({
|
||||
});
|
||||
const photoUpload = multer({
|
||||
storage: photoStorage,
|
||||
limits: { fileSize: 100 * 1024 * 1024 }, // 100MB per file for v1
|
||||
// CVE-2026-82333: single unnamed `photo` field only — no legitimate
|
||||
// array-indexed field names, so reject any bracket-index field name.
|
||||
limits: { fileSize: 100 * 1024 * 1024, fieldArrayIndexLimit: 0 }, // 100MB per file for v1
|
||||
fileFilter: (_req, file, cb) => {
|
||||
if (/^image\//.test(file.mimetype)) cb(null, true);
|
||||
else cb(new Error('Only image uploads are accepted on this endpoint'));
|
||||
@@ -146,7 +149,7 @@ router.post(
|
||||
async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() });
|
||||
if (!errors.isEmpty()) return res.status(400).json({ errors: safeValidationErrors(errors) });
|
||||
const {
|
||||
event_name, event_type, event_date,
|
||||
customer_name = null, customer_email = null, customer_phone = null,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
const { DatabaseBackupService } = require('../databaseBackup');
|
||||
const { db } = require('../../database/db');
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
@@ -9,6 +8,10 @@ jest.mock('../../database/db');
|
||||
jest.mock('../../utils/logger');
|
||||
jest.mock('../emailProcessor');
|
||||
jest.mock('child_process');
|
||||
jest.mock('node-cron', () => ({ schedule: jest.fn(() => ({ stop: jest.fn() })) }));
|
||||
|
||||
const { DatabaseBackupService, startScheduledBackups, databaseBackupService, isUnderPubliclyServableRoot } = require('../databaseBackup');
|
||||
const cron = require('node-cron');
|
||||
|
||||
describe('DatabaseBackupService', () => {
|
||||
let service;
|
||||
@@ -188,6 +191,213 @@ describe('DatabaseBackupService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('backup() destination path resolution (#1365)', () => {
|
||||
// getBackupConfig() returns database_backup_*-prefixed keys.
|
||||
// Regression: backup() used to destructure the unprefixed names
|
||||
// (`destinationPath`, ...) straight off that object, which never
|
||||
// matched, so the configured path was silently ignored and every
|
||||
// run tried to create the hardcoded /backup/database default.
|
||||
it('creates the directory from database_backup_destination_path when configured', async () => {
|
||||
db.mockReturnValue({
|
||||
where: jest.fn().mockReturnThis(),
|
||||
select: jest.fn().mockResolvedValue([
|
||||
{ setting_key: 'database_backup_destination_path', setting_value: JSON.stringify('/data/db-backups') }
|
||||
])
|
||||
});
|
||||
|
||||
const stop = new Error('stop after mkdir — nothing past it matters for this test');
|
||||
const mkdirSpy = jest.spyOn(fs, 'mkdir').mockRejectedValue(stop);
|
||||
|
||||
await expect(service.backup({})).rejects.toThrow(stop.message);
|
||||
|
||||
expect(mkdirSpy).toHaveBeenCalledWith('/data/db-backups', { recursive: true });
|
||||
mkdirSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('falls back to /backup/database only when nothing is configured', async () => {
|
||||
db.mockReturnValue({
|
||||
where: jest.fn().mockReturnThis(),
|
||||
select: jest.fn().mockResolvedValue([])
|
||||
});
|
||||
|
||||
const stop = new Error('stop after mkdir');
|
||||
const mkdirSpy = jest.spyOn(fs, 'mkdir').mockRejectedValue(stop);
|
||||
|
||||
await expect(service.backup({})).rejects.toThrow(stop.message);
|
||||
|
||||
expect(mkdirSpy).toHaveBeenCalledWith('/backup/database', { recursive: true });
|
||||
mkdirSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isUnderPubliclyServableRoot (GHSA-jw8m class, #1365)', () => {
|
||||
const originalStoragePath = process.env.STORAGE_PATH;
|
||||
const storage = '/tmp/picpeak-test-storage';
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.STORAGE_PATH = storage;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
if (originalStoragePath === undefined) {
|
||||
delete process.env.STORAGE_PATH;
|
||||
} else {
|
||||
process.env.STORAGE_PATH = originalStoragePath;
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
path.join(storage, 'uploads', 'logos'),
|
||||
path.join(storage, 'uploads', 'logos', 'sub'),
|
||||
path.join(storage, 'uploads', 'favicons'),
|
||||
path.join(storage, 'fonts'),
|
||||
path.join(storage, 'fonts', 'inter'),
|
||||
// Bundled fallback fonts — nodejs-owned per the Dockerfile's
|
||||
// COPY --chown, and served at the same public /fonts route.
|
||||
path.resolve(__dirname, '../../../assets/fonts'),
|
||||
// Case-insensitive-but-preserving filesystems (APFS, NTFS, Docker
|
||||
// Desktop bind mounts of either) resolve this to the same directory
|
||||
// as uploads/logos even though path.resolve() never folds case.
|
||||
path.join(storage, 'UPLOADS', 'Logos')
|
||||
])('flags %s as publicly servable', (candidate) => {
|
||||
expect(isUnderPubliclyServableRoot(candidate)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
path.join(storage, 'backups'),
|
||||
path.join(storage, 'uploads', 'contracts', 'signed'),
|
||||
path.join(storage, 'uploads', 'transfers', '123'),
|
||||
'/data/db-backups'
|
||||
])('does not flag %s', (candidate) => {
|
||||
expect(isUnderPubliclyServableRoot(candidate)).toBe(false);
|
||||
});
|
||||
|
||||
it('backup() refuses a destination inside a publicly servable root without ever calling mkdir', async () => {
|
||||
const publicPath = path.join(storage, 'uploads', 'logos');
|
||||
db.mockReturnValue({
|
||||
where: jest.fn().mockReturnThis(),
|
||||
select: jest.fn().mockResolvedValue([
|
||||
{ setting_key: 'database_backup_destination_path', setting_value: JSON.stringify(publicPath) }
|
||||
])
|
||||
});
|
||||
|
||||
const mkdirSpy = jest.spyOn(fs, 'mkdir');
|
||||
|
||||
await expect(service.backup({})).rejects.toThrow('publicly served directory');
|
||||
|
||||
expect(mkdirSpy).not.toHaveBeenCalled();
|
||||
mkdirSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('flags FRONTEND_DIR — the all-in-one image serves its built SPA unauthenticated', () => {
|
||||
const originalFrontendDir = process.env.FRONTEND_DIR;
|
||||
process.env.FRONTEND_DIR = '/app/frontend/dist';
|
||||
try {
|
||||
expect(isUnderPubliclyServableRoot('/app/frontend/dist')).toBe(true);
|
||||
expect(isUnderPubliclyServableRoot(path.join('/app/frontend/dist', 'assets'))).toBe(true);
|
||||
} finally {
|
||||
if (originalFrontendDir === undefined) delete process.env.FRONTEND_DIR;
|
||||
else process.env.FRONTEND_DIR = originalFrontendDir;
|
||||
}
|
||||
});
|
||||
|
||||
it('resolves a symlinked alias of a public root to the same real directory (all-in-one /app/storage -> /data/storage)', async () => {
|
||||
const os = require('os');
|
||||
const realRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'picpeak-real-'));
|
||||
const linkRoot = path.join(os.tmpdir(), `picpeak-link-${process.pid}-${Date.now()}`);
|
||||
await fs.mkdir(path.join(realRoot, 'uploads', 'logos'), { recursive: true });
|
||||
await fs.symlink(realRoot, linkRoot, 'dir');
|
||||
|
||||
try {
|
||||
// STORAGE_PATH (what the guard's roots are built from) is the real
|
||||
// path; the attacker-supplied destination goes through the symlink
|
||||
// — exactly the all-in-one image's /app/storage -> /data/storage.
|
||||
process.env.STORAGE_PATH = realRoot;
|
||||
const aliased = path.join(linkRoot, 'uploads', 'logos');
|
||||
|
||||
expect(isUnderPubliclyServableRoot(aliased)).toBe(true);
|
||||
} finally {
|
||||
await fs.unlink(linkRoot);
|
||||
await fs.rm(realRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('startScheduledBackups (#1365)', () => {
|
||||
// Same key-mismatch bug as backup(): getBackupConfig() returns
|
||||
// database_backup_*-prefixed keys, but this read `config.enabled` /
|
||||
// `config.schedule` / `config.retentionDays` — always undefined, so
|
||||
// the scheduler silently treated every install as disabled.
|
||||
it('does not start the schedule while database_backup_enabled is false', async () => {
|
||||
db.mockReturnValue({
|
||||
where: jest.fn().mockReturnThis(),
|
||||
select: jest.fn().mockResolvedValue([
|
||||
{ setting_key: 'database_backup_enabled', setting_value: 'false' }
|
||||
])
|
||||
});
|
||||
|
||||
await startScheduledBackups();
|
||||
|
||||
expect(cron.schedule).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('starts the schedule with the configured cron when database_backup_enabled is true', async () => {
|
||||
db.mockReturnValue({
|
||||
where: jest.fn().mockReturnThis(),
|
||||
select: jest.fn().mockResolvedValue([
|
||||
{ setting_key: 'database_backup_enabled', setting_value: 'true' },
|
||||
{ setting_key: 'database_backup_schedule', setting_value: JSON.stringify('0 4 * * *') }
|
||||
])
|
||||
});
|
||||
|
||||
await startScheduledBackups();
|
||||
|
||||
expect(cron.schedule).toHaveBeenCalledWith('0 4 * * *', expect.any(Function));
|
||||
});
|
||||
|
||||
it('re-reads retention on every tick instead of the value captured at schedule start (#1365)', async () => {
|
||||
db.mockReturnValue({
|
||||
where: jest.fn().mockReturnThis(),
|
||||
select: jest.fn().mockResolvedValue([
|
||||
{ setting_key: 'database_backup_enabled', setting_value: 'true' },
|
||||
{ setting_key: 'database_backup_retention_days', setting_value: JSON.stringify(30) }
|
||||
])
|
||||
});
|
||||
|
||||
await startScheduledBackups();
|
||||
const tick = cron.schedule.mock.calls[0][1];
|
||||
|
||||
// A /config update between schedule-start and this tick raised
|
||||
// retention to 365 — the closed-over 30 must not be what runs.
|
||||
db.mockReturnValue({
|
||||
where: jest.fn().mockReturnThis(),
|
||||
select: jest.fn().mockResolvedValue([
|
||||
{ setting_key: 'database_backup_enabled', setting_value: 'true' },
|
||||
{ setting_key: 'database_backup_retention_days', setting_value: JSON.stringify(365) }
|
||||
])
|
||||
});
|
||||
jest.spyOn(databaseBackupService, 'backup').mockResolvedValue({ success: true });
|
||||
const cleanupSpy = jest.spyOn(databaseBackupService, 'cleanupOldBackups').mockResolvedValue(undefined);
|
||||
|
||||
await tick();
|
||||
|
||||
expect(cleanupSpy).toHaveBeenCalledWith(365);
|
||||
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleanupOldBackups destructive-retention guard (#1365)', () => {
|
||||
it.each([-1, 0, NaN, Infinity])('refuses retentionDays=%s without touching the database', async (bad) => {
|
||||
const dbSpy = jest.fn();
|
||||
db.mockImplementation(dbSpy);
|
||||
|
||||
await service.cleanupOldBackups(bad);
|
||||
|
||||
expect(dbSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleanupOldBackups', () => {
|
||||
it('should delete old backup files and records', async () => {
|
||||
const oldBackups = [
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
jest.mock('../../utils/logger');
|
||||
jest.mock('fluent-ffmpeg');
|
||||
jest.mock('../storage', () => ({
|
||||
getStorage: jest.fn()
|
||||
}));
|
||||
jest.mock('../imageProcessor', () => ({
|
||||
generateVideoPlaceholder: jest.fn(),
|
||||
DEFAULT_THUMBNAIL_WIDTH: 300,
|
||||
DEFAULT_THUMBNAIL_HEIGHT: 300
|
||||
}));
|
||||
|
||||
const ffmpeg = require('fluent-ffmpeg');
|
||||
const { getStorage } = require('../storage');
|
||||
const { generateVideoPlaceholder } = require('../imageProcessor');
|
||||
const {
|
||||
extractVideoMetadata,
|
||||
processUploadedVideo
|
||||
} = require('../videoProcessor');
|
||||
|
||||
describe('extractVideoMetadata (#1370)', () => {
|
||||
afterEach(() => jest.clearAllMocks());
|
||||
|
||||
it('returns null duration rather than 0 when ffprobe has none, so "unknown" and "a real 0s clip" stay distinguishable', async () => {
|
||||
ffmpeg.ffprobe = jest.fn((videoPath, cb) => {
|
||||
cb(null, {
|
||||
streams: [{ codec_type: 'video', width: 1920, height: 1080, codec_name: 'hevc' }],
|
||||
format: {} // no duration field at all
|
||||
});
|
||||
});
|
||||
|
||||
const metadata = await extractVideoMetadata('/tmp/video.mp4');
|
||||
|
||||
expect(metadata.duration).toBeNull();
|
||||
expect(metadata.width).toBe(1920);
|
||||
expect(metadata.videoCodec).toBe('hevc');
|
||||
});
|
||||
|
||||
it('floors a real duration', async () => {
|
||||
ffmpeg.ffprobe = jest.fn((videoPath, cb) => {
|
||||
cb(null, { streams: [], format: { duration: 12.9 } });
|
||||
});
|
||||
|
||||
const metadata = await extractVideoMetadata('/tmp/video.mp4');
|
||||
|
||||
expect(metadata.duration).toBe(12);
|
||||
});
|
||||
});
|
||||
|
||||
describe('processUploadedVideo degrades gracefully instead of rejecting the whole video (#1370)', () => {
|
||||
let storage;
|
||||
|
||||
beforeEach(() => {
|
||||
storage = { putFromFile: jest.fn().mockResolvedValue(undefined), exists: jest.fn().mockResolvedValue(true) };
|
||||
getStorage.mockReturnValue(storage);
|
||||
generateVideoPlaceholder.mockResolvedValue('thumbnails/thumb_placeholder.jpg');
|
||||
});
|
||||
|
||||
afterEach(() => jest.clearAllMocks());
|
||||
|
||||
it('keeps the thumbnail when only metadata extraction fails', async () => {
|
||||
ffmpeg.ffprobe = jest.fn((videoPath, cb) => cb(new Error('moov atom not found')));
|
||||
ffmpeg.mockImplementation(() => ({
|
||||
screenshots: jest.fn(function screenshots({ filename, folder }) {
|
||||
require('fs').writeFileSync(require('path').join(folder, filename), 'jpeg-bytes');
|
||||
return this;
|
||||
}),
|
||||
on(event, handler) {
|
||||
if (event === 'end') setImmediate(handler);
|
||||
return this;
|
||||
}
|
||||
}));
|
||||
|
||||
const result = await processUploadedVideo('/tmp/video.mp4', 'thumbnails/thumb_video.jpg');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.metadata).toBeNull();
|
||||
expect(result.thumbnailKey).toBe('thumbnails/thumb_video.jpg');
|
||||
// A real thumbnail already succeeded — never touch the placeholder path.
|
||||
expect(generateVideoPlaceholder).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to the SVG placeholder when thumbnail generation fails, so the gallery never falls back to rendering the raw video as an <img> (codex review)', async () => {
|
||||
ffmpeg.ffprobe = jest.fn((videoPath, cb) => {
|
||||
cb(null, {
|
||||
streams: [{ codec_type: 'video', width: 1080, height: 1920, codec_name: 'h264' }],
|
||||
format: { duration: 5.4 }
|
||||
});
|
||||
});
|
||||
ffmpeg.mockImplementation(() => ({
|
||||
screenshots() { return this; },
|
||||
on(event, handler) {
|
||||
if (event === 'error') setImmediate(() => handler(new Error('ffmpeg seek failed')));
|
||||
return this;
|
||||
}
|
||||
}));
|
||||
|
||||
const result = await processUploadedVideo('/tmp/video.mp4', 'thumbnails/thumb_wedding_001.jpg');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.metadata).toEqual(expect.objectContaining({ duration: 5, videoCodec: 'h264' }));
|
||||
// thumbnailKey is always thumbnails/thumb_<name>.jpg — strip the prefix
|
||||
// back to a filename so generateVideoPlaceholder recomputes the same key.
|
||||
// Explicit width/height so generateVideoPlaceholder skips its DB-backed
|
||||
// settings lookup — this can run inside an open per-file SQLite
|
||||
// transaction (chunked video upload), where that lookup deadlocks.
|
||||
expect(generateVideoPlaceholder).toHaveBeenCalledWith('wedding_001.jpg', { width: 300, height: 300 });
|
||||
expect(result.thumbnailKey).toBe('thumbnails/thumb_placeholder.jpg');
|
||||
expect(storage.putFromFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws when metadata, thumbnail generation, AND the placeholder all fail, so the caller surfaces a retryable failure instead of completing with nothing to show (codex review)', async () => {
|
||||
ffmpeg.ffprobe = jest.fn((videoPath, cb) => cb(new Error('Invalid data found when processing input')));
|
||||
ffmpeg.mockImplementation(() => ({
|
||||
screenshots() { return this; },
|
||||
on(event, handler) {
|
||||
if (event === 'error') setImmediate(() => handler(new Error('ffmpeg seek failed')));
|
||||
return this;
|
||||
}
|
||||
}));
|
||||
generateVideoPlaceholder.mockRejectedValue(new Error('sharp render failed'));
|
||||
|
||||
await expect(processUploadedVideo('/tmp/corrupt.mp4', 'thumbnails/thumb_corrupt.jpg'))
|
||||
.rejects.toThrow('Unable to generate any thumbnail');
|
||||
});
|
||||
});
|
||||
@@ -1043,7 +1043,7 @@ function buildManifestFiles(backedUpFiles, allFiles) {
|
||||
|
||||
async function saveManifestToLocal(manifest, manifestFileName, config) {
|
||||
const manifestDir = config.backup_manifest_path
|
||||
|| path.join(config.backup_destination_path || '/backup', 'manifests');
|
||||
|| path.join(config.backup_destination_path || path.join(getStoragePath(), 'backups'), 'manifests');
|
||||
await fs.mkdir(manifestDir, { recursive: true });
|
||||
const manifestPath = path.join(manifestDir, manifestFileName);
|
||||
await backupManifest.saveManifest(manifest, manifestPath, config.backup_manifest_format || 'json');
|
||||
|
||||
@@ -360,6 +360,56 @@ Rechnung {{invoice_number}} für {{customer_name}}{{#if event_name}} ({{event_na
|
||||
Automatische Benachrichtigung — keine Aktion erforderlich.`,
|
||||
},
|
||||
},
|
||||
invoice_payment_check_action_recorded: {
|
||||
// GHSA-wg94-f86h-vq68 hardening: the payment-check link at
|
||||
// /payment-check/:token is unauthenticated by design (see
|
||||
// publicPaymentCheck.js) — token possession is the only gate.
|
||||
// This notifies the admin every time that link is used to write
|
||||
// to the invoice ledger, so the no-login convenience stays but an
|
||||
// admin always sees the action happen.
|
||||
category: 'billing', feature_flag: 'bills',
|
||||
variables: ['invoice_number', 'customer_name', 'event_name', 'action', 'has_amount', 'amount', 'ip', 'recorded_at'],
|
||||
en: {
|
||||
subject: 'Payment-check action recorded: invoice {{invoice_number}}',
|
||||
body_html: `<h2>Payment-check link used</h2>
|
||||
<p>Someone used the unauthenticated payment-check link for invoice <strong>{{invoice_number}}</strong>{{#if customer_name}} ({{customer_name}}){{/if}}{{#if event_name}}, {{event_name}}{{/if}} and recorded: <strong>{{action}}</strong>{{#if has_amount}} ({{amount}}){{/if}}.</p>
|
||||
<table role="presentation" cellpadding="6" cellspacing="0" border="0" style="border-collapse: collapse; margin: 16px 0;">
|
||||
<tr><td style="color: #666;">Action</td><td><strong>{{action}}</strong></td></tr>
|
||||
{{#if has_amount}}<tr><td style="color: #666;">Amount</td><td>{{amount}}</td></tr>{{/if}}
|
||||
<tr><td style="color: #666;">IP address</td><td>{{ip}}</td></tr>
|
||||
<tr><td style="color: #666;">Recorded at</td><td>{{recorded_at}}</td></tr>
|
||||
</table>
|
||||
<p style="font-size: 13px; color: #666;">This link requires no login — only the token in the URL. If you don't recognise this action, review the invoice in the admin panel.</p>`,
|
||||
body_text: `Payment-check link used
|
||||
|
||||
Invoice {{invoice_number}}{{#if customer_name}} ({{customer_name}}){{/if}}{{#if event_name}}, {{event_name}}{{/if}} — recorded: {{action}}{{#if has_amount}} ({{amount}}){{/if}}.
|
||||
|
||||
IP address: {{ip}}
|
||||
Recorded at: {{recorded_at}}
|
||||
|
||||
This link requires no login — only the token in the URL. If you don't recognise this action, review the invoice in the admin panel.`,
|
||||
},
|
||||
de: {
|
||||
subject: 'Zahlungsprüfung ausgelöst: Rechnung {{invoice_number}}',
|
||||
body_html: `<h2>Zahlungsprüfungs-Link verwendet</h2>
|
||||
<p>Der nicht-authentifizierte Zahlungsprüfungs-Link für Rechnung <strong>{{invoice_number}}</strong>{{#if customer_name}} ({{customer_name}}){{/if}}{{#if event_name}}, {{event_name}}{{/if}} wurde verwendet und hat erfasst: <strong>{{action}}</strong>{{#if has_amount}} ({{amount}}){{/if}}.</p>
|
||||
<table role="presentation" cellpadding="6" cellspacing="0" border="0" style="border-collapse: collapse; margin: 16px 0;">
|
||||
<tr><td style="color: #666;">Aktion</td><td><strong>{{action}}</strong></td></tr>
|
||||
{{#if has_amount}}<tr><td style="color: #666;">Betrag</td><td>{{amount}}</td></tr>{{/if}}
|
||||
<tr><td style="color: #666;">IP-Adresse</td><td>{{ip}}</td></tr>
|
||||
<tr><td style="color: #666;">Erfasst am</td><td>{{recorded_at}}</td></tr>
|
||||
</table>
|
||||
<p style="font-size: 13px; color: #666;">Dieser Link erfordert kein Login — nur den Token in der URL. Falls Ihnen diese Aktion unbekannt vorkommt, prüfen Sie die Rechnung im Admin-Bereich.</p>`,
|
||||
body_text: `Zahlungsprüfungs-Link verwendet
|
||||
|
||||
Rechnung {{invoice_number}}{{#if customer_name}} ({{customer_name}}){{/if}}{{#if event_name}}, {{event_name}}{{/if}} — erfasst: {{action}}{{#if has_amount}} ({{amount}}){{/if}}.
|
||||
|
||||
IP-Adresse: {{ip}}
|
||||
Erfasst am: {{recorded_at}}
|
||||
|
||||
Dieser Link erfordert kein Login — nur den Token in der URL. Falls Ihnen diese Aktion unbekannt vorkommt, prüfen Sie die Rechnung im Admin-Bereich.`,
|
||||
},
|
||||
},
|
||||
invoice_collections_handoff: {
|
||||
category: 'billing', feature_flag: 'bills',
|
||||
variables: ['invoice_number', 'customer_name', 'customer_email', 'customer_address', 'event_name', 'original_amount', 'late_fee_amount', 'paid_amount', 'outstanding_amount', 'due_date', 'reminder_level'],
|
||||
|
||||
@@ -4,7 +4,7 @@ const crypto = require('crypto');
|
||||
const { spawnAsync, spawnToFile } = require('../utils/safeExec');
|
||||
const zlib = require('zlib');
|
||||
const { pipeline } = require('stream/promises');
|
||||
const { createReadStream, createWriteStream } = require('fs');
|
||||
const { createReadStream, createWriteStream, realpathSync } = require('fs');
|
||||
const { db } = require('../database/db');
|
||||
const knexConfig = require('../../knexfile');
|
||||
const logger = require('../utils/logger');
|
||||
@@ -16,6 +16,76 @@ const packageJson = require('../../package.json');
|
||||
const CHUNK_SIZE = 1024 * 1024; // 1MB chunks for streaming
|
||||
const PROGRESS_INTERVAL = 100; // Report progress every 100 rows
|
||||
|
||||
function getStoragePath() {
|
||||
return process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
}
|
||||
|
||||
// Public, unauthenticated static mounts (server.js) that must never become a
|
||||
// backup destination — a dump landing there is downloadable by anyone who
|
||||
// learns or guesses the filename, GHSA-jw8m-43r2-jqrm's exact class. Before
|
||||
// #1365, `database_backup_destination_path` was silently ignored (a
|
||||
// destructuring bug always fell back to the hardcoded /backup/database), so
|
||||
// this setting being freely writable by any backup.create holder — the
|
||||
// built-in `admin` role has it without settings.edit or backup.restore — was
|
||||
// harmless. Making the setting actually take effect reopens that exact
|
||||
// exfiltration path unless it's rejected here too.
|
||||
function getPubliclyServableRoots() {
|
||||
const storage = getStoragePath();
|
||||
return [
|
||||
path.join(storage, 'uploads', 'logos'),
|
||||
path.join(storage, 'uploads', 'favicons'),
|
||||
path.join(storage, 'fonts'),
|
||||
// Bundled fallback fonts (server.js mounts both at /fonts, storage wins
|
||||
// on overlap but express.static falls through to this one on a miss).
|
||||
// COPY --chown=nodejs:nodejs in the Dockerfile makes this nodejs-owned
|
||||
// and therefore writable at runtime, not just a read-only image layer.
|
||||
path.resolve(__dirname, '../../assets/fonts'),
|
||||
// The all-in-one image's built frontend bundle (Dockerfile.aio ships it
|
||||
// nodejs-owned) — server.js serves it unauthenticated as the SPA itself.
|
||||
process.env.FRONTEND_DIR || path.resolve(__dirname, '../../../frontend/dist')
|
||||
];
|
||||
}
|
||||
|
||||
// Resolves symlinks in whatever prefix of candidatePath currently exists,
|
||||
// then re-appends any not-yet-created remainder literally. A plain
|
||||
// fs.realpathSync would throw ENOENT for the common case where the backup
|
||||
// destination doesn't exist yet; a plain path.resolve() would miss the
|
||||
// all-in-one image's `/app/storage -> /data/storage` symlink (Dockerfile.aio),
|
||||
// which lets `/app/storage/uploads/logos` alias the real public logos
|
||||
// directory under a name that never lexically matches it.
|
||||
function resolveRealish(candidatePath) {
|
||||
let current = path.resolve(candidatePath);
|
||||
const remainder = [];
|
||||
for (;;) {
|
||||
try {
|
||||
const real = realpathSync(current);
|
||||
return remainder.length ? path.join(real, ...remainder) : real;
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') {
|
||||
return path.resolve(candidatePath);
|
||||
}
|
||||
const parent = path.dirname(current);
|
||||
if (parent === current) {
|
||||
return path.resolve(candidatePath);
|
||||
}
|
||||
remainder.unshift(path.basename(current));
|
||||
current = parent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isUnderPubliclyServableRoot(candidatePath) {
|
||||
// Lowercased comparison: on a case-insensitive-but-preserving filesystem
|
||||
// (default macOS APFS, NTFS, and Docker Desktop's bind-mount passthrough
|
||||
// of either) `STORAGE_PATH/UPLOADS/logos` and `.../uploads/logos` name the
|
||||
// same directory on disk even though path.resolve() never folds case.
|
||||
const resolved = resolveRealish(candidatePath).toLowerCase();
|
||||
return getPubliclyServableRoots().some((root) => {
|
||||
const resolvedRoot = resolveRealish(root).toLowerCase();
|
||||
return resolved === resolvedRoot || resolved.startsWith(resolvedRoot + path.sep);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Database Backup Service
|
||||
* Supports both SQLite and PostgreSQL with proper escaping,
|
||||
@@ -296,15 +366,33 @@ class DatabaseBackupService {
|
||||
let backupRun = null;
|
||||
|
||||
try {
|
||||
// Get configuration
|
||||
// Get configuration. getBackupConfig() returns the raw
|
||||
// database_backup_*-prefixed setting keys, not the unprefixed
|
||||
// names used internally below — map them explicitly rather than
|
||||
// spreading `config` straight into the destructure, which silently
|
||||
// matched nothing and always fell through to the hardcoded
|
||||
// defaults (notably `/backup/database`, regardless of what was
|
||||
// configured).
|
||||
const config = await this.getBackupConfig();
|
||||
const {
|
||||
destinationPath = '/backup/database',
|
||||
compress = true,
|
||||
validateIntegrity = true,
|
||||
includeChecksums = true
|
||||
} = { ...config, ...options };
|
||||
} = {
|
||||
destinationPath: config.database_backup_destination_path,
|
||||
compress: config.database_backup_compress,
|
||||
validateIntegrity: config.database_backup_validate_integrity,
|
||||
includeChecksums: config.database_backup_include_checksums,
|
||||
...options
|
||||
};
|
||||
|
||||
if (isUnderPubliclyServableRoot(destinationPath)) {
|
||||
throw new Error(
|
||||
`Refusing to write a database backup to a publicly served directory: ${destinationPath}`
|
||||
);
|
||||
}
|
||||
|
||||
// Create backup directory
|
||||
await fs.mkdir(destinationPath, { recursive: true });
|
||||
|
||||
@@ -423,7 +511,7 @@ class DatabaseBackupService {
|
||||
logger.info(`Database backup completed: ${finalFile} (${(finalStats.size / 1024 / 1024).toFixed(2)} MB) in ${durationSeconds}s`);
|
||||
|
||||
// Send success notification if configured
|
||||
if (config.emailOnSuccess) {
|
||||
if (config.database_backup_email_on_success) {
|
||||
await this.sendBackupNotification('success', {
|
||||
duration: durationSeconds,
|
||||
size: finalStats.size,
|
||||
@@ -457,7 +545,7 @@ class DatabaseBackupService {
|
||||
|
||||
// Send failure notification
|
||||
const config = await this.getBackupConfig();
|
||||
if (config.emailOnFailure) {
|
||||
if (config.database_backup_email_on_failure) {
|
||||
await this.sendBackupNotification('failure', {
|
||||
error: error.message
|
||||
});
|
||||
@@ -538,10 +626,19 @@ class DatabaseBackupService {
|
||||
* Clean up old backups
|
||||
*/
|
||||
async cleanupOldBackups(retentionDays = 30) {
|
||||
// A zero/negative/non-finite value pushes the cutoff to today or the
|
||||
// future, matching (and deleting) every completed backup — including
|
||||
// the one a scheduled run just created. Defense in depth: PUT /config
|
||||
// already rejects such values, but this is also reachable with
|
||||
// whatever database_backup_retention_days happens to be persisted.
|
||||
if (!Number.isFinite(retentionDays) || retentionDays < 1) {
|
||||
logger.error(`Refusing to clean up backups with invalid retentionDays: ${retentionDays}`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const cutoffDate = new Date();
|
||||
cutoffDate.setDate(cutoffDate.getDate() - retentionDays);
|
||||
|
||||
|
||||
// Get old backup records
|
||||
const oldBackups = await db('database_backup_runs')
|
||||
.where('completed_at', '<', cutoffDate)
|
||||
@@ -686,25 +783,30 @@ async function startScheduledBackups() {
|
||||
|
||||
try {
|
||||
const config = await databaseBackupService.getBackupConfig();
|
||||
|
||||
if (!config.enabled) {
|
||||
|
||||
if (!config.database_backup_enabled) {
|
||||
logger.info('Database backup service is disabled');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Stop existing schedule
|
||||
if (backupSchedule) {
|
||||
backupSchedule.stop();
|
||||
}
|
||||
|
||||
|
||||
// Default schedule: 3 AM daily (offset from file backups at 2 AM)
|
||||
const schedule = config.schedule || '0 3 * * *';
|
||||
|
||||
const schedule = config.database_backup_schedule || '0 3 * * *';
|
||||
|
||||
backupSchedule = cron.schedule(schedule, async () => {
|
||||
logger.info('Starting scheduled database backup');
|
||||
try {
|
||||
await databaseBackupService.backup();
|
||||
await databaseBackupService.cleanupOldBackups(config.retentionDays || 30);
|
||||
// Re-read retention on every tick rather than closing over the value
|
||||
// from schedule start — a retention-only /config update doesn't
|
||||
// restart the schedule (only enabled/schedule changes do), so the
|
||||
// closed-over value would otherwise run stale until next restart.
|
||||
const latestConfig = await databaseBackupService.getBackupConfig();
|
||||
await databaseBackupService.cleanupOldBackups(latestConfig.database_backup_retention_days || 30);
|
||||
} catch (error) {
|
||||
logger.error('Scheduled database backup failed:', error);
|
||||
}
|
||||
@@ -731,5 +833,6 @@ module.exports = {
|
||||
databaseBackupService,
|
||||
startScheduledBackups,
|
||||
stopScheduledBackups,
|
||||
isUnderPubliclyServableRoot,
|
||||
DatabaseBackupService // Export class for testing
|
||||
};
|
||||
@@ -24,15 +24,52 @@ const watermarkService = require('./watermarkService');
|
||||
const { resolvePhotoStorageKey, resolvePhotoFilePath } = require('./photoResolver');
|
||||
const { getStorage } = require('./storage');
|
||||
const { getUseOriginalFilenames, getZipEntryNames } = require('./downloadFilenameService');
|
||||
const { createArchiveStreamGuard } = require('../utils/archiveStreamGuard');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
const DEBOUNCE_MS = 5000;
|
||||
|
||||
// How many cached zips may be REBUILT at once in the background.
|
||||
//
|
||||
// invalidateAll() invalidates every event that has a cached zip, and each
|
||||
// invalidate() arms its own debounce timer in the same tick — so they all fire
|
||||
// together and, before this, every one of them started building at once. Each
|
||||
// build opens its own storage reads, so 25 events was enough to exhaust the S3
|
||||
// agent pool and stall uploads, thumbnails and gallery reads until the burst
|
||||
// finished.
|
||||
//
|
||||
// This caps the BACKGROUND path only. A foreground generateZip() — a guest
|
||||
// actually waiting for a download — is never queued behind a rebuild.
|
||||
const MAX_CONCURRENT_REGENS = 2;
|
||||
|
||||
class DownloadZipService {
|
||||
constructor() {
|
||||
this.activeBuilds = new Map(); // eventId -> { promise, version }
|
||||
this.debounceTimers = new Map(); // eventId -> setTimeout handle
|
||||
this.versions = new Map(); // eventId -> generation counter
|
||||
this.buildCancellers = new Map(); // eventId -> abort the in-flight build
|
||||
this.regenActive = 0; // background rebuilds running right now
|
||||
this.regenWaiters = []; // resolvers parked waiting for a slot
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a BACKGROUND rebuild under the concurrency cap. Foreground callers
|
||||
* deliberately do not go through here: someone is waiting on that response,
|
||||
* and making them queue behind a settings-change burst would trade one stall
|
||||
* for another.
|
||||
*/
|
||||
async _withRegenSlot(fn) {
|
||||
if (this.regenActive >= MAX_CONCURRENT_REGENS) {
|
||||
await new Promise((resolve) => this.regenWaiters.push(resolve));
|
||||
}
|
||||
this.regenActive += 1;
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
this.regenActive -= 1;
|
||||
const next = this.regenWaiters.shift();
|
||||
if (next) next();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -107,6 +144,7 @@ class DownloadZipService {
|
||||
async _build(eventId, version) {
|
||||
const storage = getStorage();
|
||||
let tmpDir;
|
||||
let buildGuard = null;
|
||||
|
||||
try {
|
||||
const event = await db('events').where({ id: eventId }).first();
|
||||
@@ -152,8 +190,28 @@ class DownloadZipService {
|
||||
const output = fs.createWriteStream(tmpPath);
|
||||
const archive = archiver('zip', { zlib: { level: 0 } });
|
||||
|
||||
// Bound and reclaim the storage reads. archiver drains its sources one
|
||||
// at a time, so appending one read per photo parks an S3 socket per
|
||||
// photo holding unread bytes; nothing reclaims them, because
|
||||
// archiver's abort() does not touch source streams and the SDK clears
|
||||
// its socket timeout as soon as response headers land. An unbounded
|
||||
// loop over a large event starves uploads, thumbnails and gallery
|
||||
// reads for the duration of the build.
|
||||
buildGuard = createArchiveStreamGuard({
|
||||
onFatalError: (err) => { buildGuard.destroyAll(); archive.abort(); reject(err); },
|
||||
});
|
||||
|
||||
// Invalidation cancels the build directly rather than leaving a note
|
||||
// for the loop: with a read cap in place the loop can be parked waiting
|
||||
// for a slot that a stalled archive will never free.
|
||||
this.buildCancellers.set(eventId, () => {
|
||||
buildGuard.destroyAll();
|
||||
archive.abort();
|
||||
reject(new Error('Build invalidated'));
|
||||
});
|
||||
|
||||
output.on('close', resolve);
|
||||
archive.on('error', reject);
|
||||
archive.on('error', (err) => { buildGuard.destroyAll(); reject(err); });
|
||||
archive.pipe(output);
|
||||
|
||||
const uniqueTypes = new Set(photos.map(p => p.type)).size;
|
||||
@@ -164,6 +222,7 @@ class DownloadZipService {
|
||||
const photo = photos[i];
|
||||
// Check if build was invalidated
|
||||
if (this.versions.get(eventId) !== version) {
|
||||
buildGuard.destroyAll();
|
||||
archive.abort();
|
||||
return reject(new Error('Build invalidated'));
|
||||
}
|
||||
@@ -203,8 +262,9 @@ class DownloadZipService {
|
||||
logger.warn('Skipping watermark in pre-zip', { photoId: photo.id, error: err.message });
|
||||
}
|
||||
} else if (storageKey) {
|
||||
if (!await buildGuard.acquire()) return;
|
||||
const stream = await storage.get(storageKey);
|
||||
archive.append(stream, { name: archiveName });
|
||||
archive.append(buildGuard.track(stream), { name: archiveName });
|
||||
} else {
|
||||
const filePath = resolvePhotoFilePath(event, photo);
|
||||
archive.file(filePath, { name: archiveName });
|
||||
@@ -243,6 +303,10 @@ class DownloadZipService {
|
||||
logger.error('downloadZipService._build error', { eventId, error: err.message });
|
||||
return { success: false, error: err.message };
|
||||
} finally {
|
||||
// Every exit path — success, invalidated, thrown — has to reclaim the
|
||||
// reads, or they hold their sockets for the life of the process.
|
||||
if (buildGuard) buildGuard.destroyAll();
|
||||
this.buildCancellers.delete(eventId);
|
||||
if (tmpDir) {
|
||||
await fsp.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
||||
}
|
||||
@@ -261,6 +325,12 @@ class DownloadZipService {
|
||||
const timer = this.debounceTimers.get(eventId);
|
||||
if (timer) clearTimeout(timer);
|
||||
|
||||
// Cancel an in-flight build directly. Bumping the version only stops it the
|
||||
// next time the loop looks, and with a read cap the loop can be parked
|
||||
// waiting for a slot a stalled archive will never free.
|
||||
const cancelBuild = this.buildCancellers.get(eventId);
|
||||
if (cancelBuild) cancelBuild();
|
||||
|
||||
// Fire-and-forget cleanup
|
||||
this._cleanup(eventId).catch(err =>
|
||||
logger.warn('downloadZipService.invalidate cleanup error', { eventId, error: err.message })
|
||||
@@ -269,7 +339,9 @@ class DownloadZipService {
|
||||
// Debounce regeneration
|
||||
const newTimer = setTimeout(() => {
|
||||
this.debounceTimers.delete(eventId);
|
||||
this.generateZip(eventId).catch(err =>
|
||||
// Through the cap: invalidateAll arms every one of these in the same
|
||||
// tick, so without it they all start building together.
|
||||
this._withRegenSlot(() => this.generateZip(eventId)).catch(err =>
|
||||
logger.warn('downloadZipService debounced regen error', { eventId, error: err.message })
|
||||
);
|
||||
}, DEBOUNCE_MS);
|
||||
|
||||
@@ -168,8 +168,19 @@ async function formatEventDate(value) {
|
||||
}
|
||||
}
|
||||
|
||||
// Draft, archived and deactivated galleries are refused by /info; the OG
|
||||
// preview must not leak their name, date and welcome message to crawlers.
|
||||
function isPubliclyVisible(event) {
|
||||
if (!event) return false;
|
||||
const truthy = (v) => v === true || v === 1 || v === '1' || v === 'true';
|
||||
if (truthy(event.is_draft) || truthy(event.is_archived)) return false;
|
||||
if (event.is_active === false || event.is_active === 0 || event.is_active === '0') return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
async function buildOgMetadata(slug, requestPath) {
|
||||
const event = await resolveSlug(slug);
|
||||
const resolved = await resolveSlug(slug);
|
||||
const event = isPubliclyVisible(resolved) ? resolved : null;
|
||||
const branding = await fetchBranding();
|
||||
const base = frontendBase();
|
||||
const siteName = branding.companyName || 'PicPeak';
|
||||
|
||||
@@ -369,9 +369,15 @@ async function generateVideoPlaceholder(originalFilename, options = {}) {
|
||||
const thumbnailRelKey = path.posix.join('thumbnails', thumbnailFilename);
|
||||
const storage = getStorage();
|
||||
|
||||
const settings = await getThumbnailSettings();
|
||||
const width = settings.width || DEFAULT_THUMBNAIL_WIDTH;
|
||||
const height = settings.height || DEFAULT_THUMBNAIL_HEIGHT;
|
||||
// Skip the settings lookup when the caller already supplies dimensions.
|
||||
// This can run from inside an open per-file SQLite transaction (chunked
|
||||
// video upload's fallback path in videoProcessor.js) — a second,
|
||||
// un-transacted db() query for settings there deadlocks against SQLite's
|
||||
// single-connection pool until acquireConnectionTimeout (60s), reproduced
|
||||
// directly against an isolated SQLite db (codex review of #1371/#1372).
|
||||
const settings = (options.width && options.height) ? {} : await getThumbnailSettings();
|
||||
const width = options.width || settings.width || DEFAULT_THUMBNAIL_WIDTH;
|
||||
const height = options.height || settings.height || DEFAULT_THUMBNAIL_HEIGHT;
|
||||
|
||||
if (options.regenerate) {
|
||||
await storage.delete(thumbnailRelKey).catch(() => {});
|
||||
@@ -864,4 +870,6 @@ module.exports = {
|
||||
ensurePreviewImage,
|
||||
extractCaptureDate,
|
||||
withLocalCopy,
|
||||
DEFAULT_THUMBNAIL_WIDTH,
|
||||
DEFAULT_THUMBNAIL_HEIGHT,
|
||||
};
|
||||
|
||||
@@ -12,6 +12,15 @@ const { ensureInt } = require('../../utils/numericHelpers');
|
||||
const { formatMajor } = require('./helpers');
|
||||
const { applyReminder, resolveAdminEmailForInvoice, resolvePerReminderFeeMinor, resolveSkontoPercentForInvoice } = require('./reminders');
|
||||
|
||||
// Payment-check token lifetime (GHSA-wg94-f86h-vq68 hardening). This
|
||||
// unauthenticated magic link is the only gate on a write to the
|
||||
// invoice ledger, so it's kept short rather than the prior 30 days.
|
||||
// The scheduler re-queues a fresh token daily (throttled by
|
||||
// last_payment_check_at, see queuePaymentCheckEmail below) for as
|
||||
// long as the invoice stays past its reminder cutoff, so a short TTL
|
||||
// doesn't strand an admin who hasn't acted yet — they just get a new
|
||||
// link on the next tick.
|
||||
const PAYMENT_CHECK_TOKEN_TTL_MS = 72 * 60 * 60 * 1000; // 72h
|
||||
|
||||
/**
|
||||
* Record a payment against an invoice. Supports partial payments
|
||||
@@ -226,7 +235,7 @@ async function queuePaymentCheckEmail(invoiceId, { skipThrottle = false } = {})
|
||||
}
|
||||
|
||||
const token = crypto.randomBytes(32).toString('hex');
|
||||
const expiresAt = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000);
|
||||
const expiresAt = new Date(now.getTime() + PAYMENT_CHECK_TOKEN_TTL_MS);
|
||||
await db('invoice_payment_check_tokens').insert({
|
||||
invoice_id: invoiceId,
|
||||
token,
|
||||
@@ -378,6 +387,45 @@ async function getPaymentCheckByToken(token) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort admin notification for every write via the public,
|
||||
* unauthenticated payment-check route (GHSA-wg94-f86h-vq68
|
||||
* hardening). Token possession is the only gate on that route, so
|
||||
* this fires on every successful action — 'paid_full', 'partial',
|
||||
* 'unpaid', 'paid_with_skonto' — regardless of what the ledger
|
||||
* effect ends up being, so an admin always sees the action happen.
|
||||
* Callers MUST wrap this in try/catch: a failed send must never
|
||||
* fail (or roll back) the ledger write it's reporting on.
|
||||
*/
|
||||
async function notifyAdminOfPaymentCheckAction({ invoice, action, amountMinor, ip }) {
|
||||
const adminContact = await resolveAdminEmailForInvoice(invoice);
|
||||
if (!adminContact?.email) {
|
||||
logger.warn('Payment-check action notification skipped — no admin email resolved',
|
||||
{ invoiceId: invoice.id, action });
|
||||
return;
|
||||
}
|
||||
|
||||
const profile = await db('business_profile').where({ id: 1 }).first();
|
||||
const locale = invoice.language || profile?.default_locale || 'de';
|
||||
const customer = await db('customer_accounts').where({ id: invoice.customer_account_id }).first();
|
||||
|
||||
await emailProcessor.queueEmail(invoice.event_id || null, adminContact.email,
|
||||
'invoice_payment_check_action_recorded', {
|
||||
invoice_number: invoice.invoice_number,
|
||||
customer_name: customer?.company_name
|
||||
|| customer?.display_name
|
||||
|| [customer?.first_name, customer?.last_name].filter(Boolean).join(' ')
|
||||
|| customer?.email || '',
|
||||
event_name: invoice.event_name || '',
|
||||
__language: locale,
|
||||
action,
|
||||
amount: amountMinor ? formatMajor(ensureInt(amountMinor), invoice.currency, locale) : '',
|
||||
has_amount: !!amountMinor,
|
||||
ip: ip || 'unknown',
|
||||
recorded_at: formatShortDate(new Date()),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Record the admin's payment-check action and fire the downstream
|
||||
* consequences:
|
||||
@@ -446,6 +494,19 @@ async function recordPaymentCheckAction({ token, action, amountMinor, ip, adminI
|
||||
adminId ? `admin:${adminId}` : 'public:payment-check');
|
||||
} catch (_) {}
|
||||
|
||||
// Notify the admin this write happened. Best-effort / non-blocking
|
||||
// — the ledger write above already committed, and a failed
|
||||
// notification send must not undo or fail it.
|
||||
if (!adminId) {
|
||||
try {
|
||||
await notifyAdminOfPaymentCheckAction({ invoice, action, amountMinor, ip });
|
||||
} catch (err) {
|
||||
logger.warn('Payment-check action admin notification failed', {
|
||||
invoiceId: invoice.id, action, err: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// --- Apply the action -----------------------------------------
|
||||
if (action === 'paid_full') {
|
||||
await markPaid(invoice.id, {
|
||||
|
||||
@@ -3,7 +3,11 @@
|
||||
*
|
||||
* Responsibilities:
|
||||
* - generate/verify TOTP secrets (otplib, standard SHA1/6-digit/30s so
|
||||
* Google Authenticator / Authy / 1Password all work);
|
||||
* Google Authenticator / Authy / 1Password all work), with replay
|
||||
* protection: verifyTotpEncryptedStep() rejects a code whose matched
|
||||
* time-step doesn't advance past the admin's last consumed one
|
||||
* (GHSA-qcwx-r25m-j869 — otplib's window:1 tolerance alone lets a
|
||||
* captured code stay valid across several time-steps, ~90s);
|
||||
* - encrypt the secret at rest (AES-256-GCM) so a DB leak alone doesn't
|
||||
* yield working authenticator seeds;
|
||||
* - generate/verify one-time recovery codes, hashed (bcrypt) and single-use;
|
||||
@@ -67,25 +71,102 @@ function decryptSecret(stored) {
|
||||
return pt.toString('utf8');
|
||||
}
|
||||
|
||||
/** Verify a 6-digit TOTP code against the (plaintext) secret. */
|
||||
function verifyTotp(code, plainSecret) {
|
||||
if (!code || !plainSecret) return false;
|
||||
/** Absolute TOTP time-step for "now" (Math.floor(Date.now() / 30000)). */
|
||||
function currentTotpStep() {
|
||||
return Math.floor(Date.now() / 30000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Core TOTP check. Returns the matched absolute time-step (always a
|
||||
* positive, truthy integer) when `code` is valid for `plainSecret`;
|
||||
* otherwise `null`.
|
||||
*
|
||||
* When `lastUsedStep` is given, a code whose matched step doesn't advance
|
||||
* past it is treated as invalid — replay protection. Without this, otplib's
|
||||
* window:1 tolerance lets a captured code stay valid across several real
|
||||
* time-steps (~90s), so the same code could complete two independent admin
|
||||
* logins (GHSA-qcwx-r25m-j869).
|
||||
*/
|
||||
function matchTotpStep(code, plainSecret, lastUsedStep) {
|
||||
if (!code || !plainSecret) return null;
|
||||
try {
|
||||
return authenticator.verify({ token: String(code).replace(/\s+/g, ''), secret: plainSecret });
|
||||
const token = String(code).replace(/\s+/g, '');
|
||||
const delta = authenticator.checkDelta(token, plainSecret);
|
||||
if (typeof delta !== 'number') return null;
|
||||
const step = currentTotpStep() + delta;
|
||||
if (typeof lastUsedStep === 'number' && step <= lastUsedStep) return null;
|
||||
return step;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a 6-digit TOTP code against the (plaintext) secret. Pass
|
||||
* `lastUsedStep` (the admin's previously-consumed step) to also enforce
|
||||
* replay protection — see matchTotpStep().
|
||||
*/
|
||||
function verifyTotp(code, plainSecret, lastUsedStep) {
|
||||
return matchTotpStep(code, plainSecret, lastUsedStep) !== null;
|
||||
}
|
||||
|
||||
/** Verify a code against a STORED (encrypted) secret. */
|
||||
function verifyTotpEncrypted(code, storedSecret, lastUsedStep) {
|
||||
try {
|
||||
return verifyTotp(code, decryptSecret(storedSecret), lastUsedStep);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Verify a code against a STORED (encrypted) secret. */
|
||||
function verifyTotpEncrypted(code, storedSecret) {
|
||||
/**
|
||||
* Like verifyTotpEncrypted(), but returns the matched step (or `null` when
|
||||
* the code is invalid/replayed) instead of a boolean, so a caller that
|
||||
* grants a session or a sensitive action can persist it as the admin's new
|
||||
* `two_factor_last_used_step`.
|
||||
*/
|
||||
function verifyTotpEncryptedStep(code, storedSecret, lastUsedStep) {
|
||||
try {
|
||||
return verifyTotp(code, decryptSecret(storedSecret));
|
||||
return matchTotpStep(code, decryptSecret(storedSecret), lastUsedStep);
|
||||
} catch {
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist a newly-matched TOTP step, but only if it still advances
|
||||
* `two_factor_last_used_step` at write time (`db('admin_users').where('id',
|
||||
* adminId).whereNull(...).orWhere(...).update(...)`).
|
||||
*
|
||||
* matchTotpStep()'s "does this advance past lastUsedStep" check is read
|
||||
* against a snapshot taken earlier in the request. Two concurrent requests
|
||||
* carrying the same captured code can both read the same lastUsedStep and
|
||||
* both pass that check before either write lands — a plain, unconditional
|
||||
* UPDATE would let both persist, defeating replay protection. Guarding the
|
||||
* UPDATE with the same condition and checking the affected-row count makes
|
||||
* only the first writer succeed; a losing concurrent request gets 0 affected
|
||||
* rows and must be treated as a replay by the caller.
|
||||
*
|
||||
* @param {object} db - knex instance
|
||||
* @param {number} adminId
|
||||
* @param {number} totpStep - matched step from verifyTotpEncryptedStep()
|
||||
* @param {object} [extraFields] - additional columns to set in the same UPDATE
|
||||
* @returns {Promise<boolean>} true if this call won the race and persisted
|
||||
*/
|
||||
async function persistTotpStep(db, adminId, totpStep, extraFields = {}) {
|
||||
const affected = await db('admin_users')
|
||||
.where('id', adminId)
|
||||
.where(function () {
|
||||
this.whereNull('two_factor_last_used_step')
|
||||
.orWhere('two_factor_last_used_step', '<', totpStep);
|
||||
})
|
||||
.update({
|
||||
two_factor_last_used_step: totpStep,
|
||||
...extraFields
|
||||
});
|
||||
return affected > 0;
|
||||
}
|
||||
|
||||
/** otpauth:// URI for an authenticator app. */
|
||||
function buildOtpauthUri(accountName, plainSecret) {
|
||||
return authenticator.keyuri(accountName, ISSUER, plainSecret);
|
||||
@@ -169,8 +250,11 @@ module.exports = {
|
||||
generateSecret,
|
||||
encryptSecret,
|
||||
decryptSecret,
|
||||
currentTotpStep,
|
||||
verifyTotp,
|
||||
verifyTotpEncrypted,
|
||||
verifyTotpEncryptedStep,
|
||||
persistTotpStep,
|
||||
buildOtpauthUri,
|
||||
buildQrDataUrl,
|
||||
generateRecoveryCodes,
|
||||
|
||||
@@ -106,8 +106,13 @@ function isAuthenticated(req) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Valid token found - check type
|
||||
req.tokenType = decoded.type; // 'admin' or 'gallery'
|
||||
// Only an admin session earns the skip. A gallery token is minted for
|
||||
// free on password-less galleries and slideshow links, so treating it as
|
||||
// "authenticated" handed anyone an unlimited budget on every /api route.
|
||||
if (decoded.type !== 'admin') {
|
||||
return false;
|
||||
}
|
||||
req.tokenType = decoded.type;
|
||||
req.tokenPayload = decoded;
|
||||
|
||||
return true;
|
||||
|
||||
@@ -22,6 +22,85 @@ function pathEscapes(baseDir, candidate) {
|
||||
const rel = path.relative(path.resolve(baseDir), path.resolve(candidate));
|
||||
return !rel || rel === '..' || rel.startsWith('..' + path.sep) || path.isAbsolute(rel);
|
||||
}
|
||||
|
||||
// GHSA-xfvx: `manifest.database.backup_file` is just as attacker-influenceable
|
||||
// as the file-manifest entries `pathEscapes` guards above (hand-crafted or
|
||||
// tampered backup manifest) — an absolute path or a `..`-laden relative one
|
||||
// must not be allowed to point the SQLite/PG restore at an arbitrary file on
|
||||
// disk. Resolve the SAME operator-configured backup roots that
|
||||
// `adminRestore.js`'s `checkRestorePathsAllowed` (GHSA-fw4c) enforces for the
|
||||
// top-level `source`/`manifestPath` request fields, plus the already-trusted
|
||||
// `backupPath` this restore run resolved to (always included, so this never
|
||||
// fails open even when no backup_destination_path/backup_manifest_path is
|
||||
// configured yet).
|
||||
async function getConfiguredBackupRoots(trustedRoot) {
|
||||
const roots = [];
|
||||
if (trustedRoot) roots.push(trustedRoot);
|
||||
try {
|
||||
const rows = await db('app_settings')
|
||||
.whereIn('setting_key', ['backup_destination_path', 'backup_manifest_path'])
|
||||
.select('setting_value');
|
||||
for (const row of rows) {
|
||||
let value;
|
||||
try { value = JSON.parse(row.setting_value); } catch (_) { value = row.setting_value; }
|
||||
if (value) roots.push(value);
|
||||
}
|
||||
} catch (_) {
|
||||
// best effort — fall through to whatever roots we already have
|
||||
}
|
||||
for (const extra of (process.env.RESTORE_ALLOWED_ROOTS || '').split(':')) {
|
||||
if (extra.trim()) roots.push(extra.trim());
|
||||
}
|
||||
return roots.map((r) => path.resolve(r));
|
||||
}
|
||||
|
||||
function isContainedInRoots(candidate, resolvedRoots) {
|
||||
const resolved = path.resolve(candidate);
|
||||
return resolvedRoots.some(
|
||||
(root) => resolved === root || resolved.startsWith(root + path.sep)
|
||||
);
|
||||
}
|
||||
|
||||
// sqlite3's `.restore`/`.backup` are dot-commands parsed by sqlite3's OWN
|
||||
// tokenizer, not the shell — spawn()'s argv separation (shell: false) does
|
||||
// NOT protect against a single quote embedded in the path breaking out of
|
||||
// the `.restore '<path>'` argument, since the whole `.restore '<path>'`
|
||||
// string is one argv element that sqlite3 re-parses itself. sqlite3 offers
|
||||
// no parameterized dot-command form, so constrain the path to a
|
||||
// conservative safe charset before it is ever interpolated (GHSA-xfvx).
|
||||
const SAFE_SQLITE_PATH_RE = /^[A-Za-z0-9._/-]+$/;
|
||||
function assertSafeSqlitePath(p) {
|
||||
if (typeof p !== 'string' || !SAFE_SQLITE_PATH_RE.test(p)) {
|
||||
throw new Error(`Refusing to run sqlite3 against an unsafe path: ${p}`);
|
||||
}
|
||||
}
|
||||
|
||||
// GHSA-xfvx: the layered candidate resolution for `manifest.database.backup_file`
|
||||
// (see performDatabaseRestore), factored out so the containment rule can be
|
||||
// pinned directly in tests without exercising the surrounding DB-swap/spawn
|
||||
// side effects. `warn` is an optional `(msg, meta) => void` logger hook.
|
||||
async function resolveContainedDbBackupCandidates(backupPath, dbBackupFile, warn) {
|
||||
const allowedRoots = await getConfiguredBackupRoots(backupPath);
|
||||
const rawCandidates = [
|
||||
// (1) Honour absolute paths recorded by the dumper.
|
||||
path.isAbsolute(dbBackupFile) ? dbBackupFile : null,
|
||||
// (2) Relative-to-backupPath as-stored (no basename munging).
|
||||
path.join(backupPath, dbBackupFile),
|
||||
// (3) Legacy reconstruct. Inherently safe: path.basename() strips any
|
||||
// directory component, so this candidate can never escape backupPath.
|
||||
path.join(backupPath, 'database', path.basename(dbBackupFile)),
|
||||
].filter(Boolean);
|
||||
|
||||
return rawCandidates.filter((candidate) => {
|
||||
const contained = isContainedInRoots(candidate, allowedRoots);
|
||||
if (!contained && warn) {
|
||||
warn('Refusing database backup candidate outside configured backup roots', {
|
||||
candidate, dbBackupFile,
|
||||
});
|
||||
}
|
||||
return contained;
|
||||
});
|
||||
}
|
||||
const { formatBytes } = require('../utils/formatBytes');
|
||||
const os = require('os');
|
||||
|
||||
@@ -892,14 +971,26 @@ class RestoreService {
|
||||
// `Database backup file not found: local/database/...sql.gz`
|
||||
// even though the file existed at exactly the path the manifest
|
||||
// recorded.
|
||||
const candidates = [
|
||||
// (1) Honour absolute paths recorded by the dumper.
|
||||
path.isAbsolute(dbBackupFile) ? dbBackupFile : null,
|
||||
// (2) Relative-to-backupPath as-stored (no basename munging).
|
||||
path.join(backupPath, dbBackupFile),
|
||||
// (3) Legacy reconstruct.
|
||||
path.join(backupPath, 'database', path.basename(dbBackupFile)),
|
||||
].filter(Boolean);
|
||||
// GHSA-xfvx: `dbBackupFile` comes straight out of the manifest, which is
|
||||
// attacker-influenceable (hand-crafted or tampered backup). Neither
|
||||
// candidate (1) nor (2) below used to be checked for containment, so a
|
||||
// manifest could point `.restore` at an arbitrary file anywhere on disk
|
||||
// (absolute path, or `../../` traversal through the path.join). Resolve
|
||||
// each candidate and drop any that escape the configured backup roots
|
||||
// BEFORE it's ever fs.access'd/candidate-listed. Candidate (3) is
|
||||
// inherently safe (path.basename() strips any directory component) and
|
||||
// is always inside `backupPath`, which is itself always one of the
|
||||
// allowed roots below.
|
||||
const candidates = await resolveContainedDbBackupCandidates(
|
||||
backupPath, dbBackupFile, (msg, meta) => this.log('warn', msg, meta)
|
||||
);
|
||||
|
||||
if (candidates.length === 0) {
|
||||
throw new Error(
|
||||
'Database backup file path is not inside a configured backup location. ' +
|
||||
`Manifest recorded path: ${dbBackupFile}.`
|
||||
);
|
||||
}
|
||||
|
||||
let dbBackupPath = null;
|
||||
for (const candidate of candidates) {
|
||||
@@ -969,7 +1060,12 @@ class RestoreService {
|
||||
await fs.copyFile(dbPath, currentBackup);
|
||||
|
||||
try {
|
||||
// Restore from backup
|
||||
// Restore from backup. `restoreFile` is contained-checked above,
|
||||
// but the FILENAME component still comes from the manifest — a
|
||||
// quote in it would break out of the `.restore '<path>'` dot-
|
||||
// command sqlite3 parses (GHSA-xfvx). Charset-validate right
|
||||
// before use as the final gate.
|
||||
assertSafeSqlitePath(restoreFile);
|
||||
await spawnAsync('sqlite3', [dbPath, `.restore '${restoreFile}'`]);
|
||||
|
||||
// Verify integrity
|
||||
@@ -1466,6 +1562,10 @@ END $$;`
|
||||
|
||||
if (this.dbType === 'sqlite') {
|
||||
const dbPath = knexConfig.connection.filename;
|
||||
// Defense in depth: same dot-command injection surface as the
|
||||
// main restore path (GHSA-xfvx), even though this path is
|
||||
// internally generated rather than manifest-controlled.
|
||||
assertSafeSqlitePath(decompressedPath);
|
||||
await spawnAsync('sqlite3', [dbPath, `.restore '${decompressedPath}'`]);
|
||||
} else {
|
||||
const { host, port, user, password, database } = knexConfig.connection;
|
||||
@@ -1559,10 +1659,42 @@ END $$;`
|
||||
throw new Error('Invalid S3 URL format');
|
||||
}
|
||||
|
||||
// SSRF guard: this method calls S3StorageAdapter.download() directly
|
||||
// rather than going through testConnection(), so it must re-run the same
|
||||
// DNS-resolving host check testConnection() applies — otherwise an
|
||||
// admin-configured S3 endpoint could point at a private/internal or
|
||||
// cloud-metadata address for unauthenticated egress via the server.
|
||||
// Prod-only, matching S3StorageAdapter's own gate (dev points at
|
||||
// localhost MinIO deliberately).
|
||||
//
|
||||
// A boolean isHostAllowed() preflight is check-then-connect: the AWS
|
||||
// SDK re-resolves the endpoint hostname on its own when it actually
|
||||
// connects, so a DNS-rebinding attacker (or an infra rebinding
|
||||
// condition) could answer the preflight lookup with a public address
|
||||
// and the SDK's own later lookup with a private/metadata one.
|
||||
// validateExternalUrlWithAddresses's resolved addresses get pinned
|
||||
// into the S3Client's requestHandler via pinnedRequestOptions, so the
|
||||
// connection can only land on an address that was actually vetted.
|
||||
let pinnedAgents = {};
|
||||
if (process.env.NODE_ENV === 'production' && s3Config && s3Config.endpoint) {
|
||||
const { validateExternalUrlWithAddresses } = require('../utils/networkValidation');
|
||||
const { pinnedRequestOptions } = require('../utils/pinnedRequest');
|
||||
const endpointUrl = /^https?:\/\//.test(s3Config.endpoint)
|
||||
? s3Config.endpoint
|
||||
: `https://${s3Config.endpoint}`;
|
||||
const urlCheck = await validateExternalUrlWithAddresses(endpointUrl);
|
||||
if (!urlCheck.valid) {
|
||||
throw new Error('S3 endpoint resolves to a private or internal network address');
|
||||
}
|
||||
const { httpAgent, httpsAgent } = pinnedRequestOptions(urlCheck);
|
||||
pinnedAgents = { httpAgent, httpsAgent };
|
||||
}
|
||||
|
||||
const [, bucket, key] = s3PathMatch;
|
||||
const s3Client = new S3StorageAdapter({
|
||||
...s3Config,
|
||||
bucket
|
||||
bucket,
|
||||
...pinnedAgents
|
||||
});
|
||||
|
||||
await s3Client.download(key, localPath);
|
||||
@@ -1736,5 +1868,14 @@ const restoreService = new RestoreService();
|
||||
|
||||
module.exports = {
|
||||
restoreService,
|
||||
RestoreService // Export class for testing
|
||||
RestoreService, // Export class for testing
|
||||
// Exposed for tests: the manifest `database.backup_file` containment +
|
||||
// sqlite dot-command charset rules (GHSA-xfvx) are worth pinning directly.
|
||||
_internal: {
|
||||
getConfiguredBackupRoots,
|
||||
isContainedInRoots,
|
||||
assertSafeSqlitePath,
|
||||
pathEscapes,
|
||||
resolveContainedDbBackupCandidates,
|
||||
},
|
||||
};
|
||||
@@ -118,7 +118,15 @@ const ACTIVE_EVENT_FILTER = {
|
||||
is_draft: formatBoolean(false)
|
||||
};
|
||||
|
||||
const resolveShareIdentifier = async (identifier) => {
|
||||
// Same filter minus the draft gate, for admin preview only (#1386). Callers
|
||||
// MUST authorize before returning anything it matched — see the /resolve
|
||||
// route, which only reaches for it after isAdminPreview accepts the caller.
|
||||
const UNPUBLISHED_EVENT_FILTER = {
|
||||
is_active: formatBoolean(true),
|
||||
is_archived: formatBoolean(false)
|
||||
};
|
||||
|
||||
const resolveShareIdentifier = async (identifier, { includeDrafts = false } = {}) => {
|
||||
if (!identifier) {
|
||||
return null;
|
||||
}
|
||||
@@ -140,9 +148,13 @@ const resolveShareIdentifier = async (identifier) => {
|
||||
'event_date',
|
||||
'expires_at',
|
||||
'is_active',
|
||||
'is_archived'
|
||||
'is_archived',
|
||||
'is_draft',
|
||||
// Ownership input for the preview check (#1411) — a draft is only
|
||||
// previewable by an admin who may see this event.
|
||||
'created_by'
|
||||
)
|
||||
.where(ACTIVE_EVENT_FILTER);
|
||||
.where(includeDrafts ? UNPUBLISHED_EVENT_FILTER : ACTIVE_EVENT_FILTER);
|
||||
|
||||
let event = await baseQuery.clone().where({ slug: trimmed }).first();
|
||||
if (event) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user