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