Compare commits
53 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1d48f59fe1 | |||
| e37d1fac58 | |||
| 9ee3ff45d0 | |||
| 5453152f1c | |||
| b416baec5c | |||
| 38ddd70c12 | |||
| b00a16159e | |||
| dcfcb67f9b | |||
| cde0b465a9 | |||
| 28f69e4bf3 | |||
| 1cf82d81a7 | |||
| ae98e7ad74 | |||
| caa9fe5d56 | |||
| c6e61f64ba | |||
| 3ec0451cbb | |||
| edac463ec3 | |||
| 2d3537f61c | |||
| 6025b3194d | |||
| 8713ab7f60 | |||
| 8994901e4a | |||
| b86669f1e1 | |||
| 80296282e8 | |||
| 5551c89bda | |||
| dbde67c0fa | |||
| 067e460a4d | |||
| 3678193ae2 | |||
| 74eacbc78f | |||
| 37bf894412 | |||
| 506b5c3dc4 | |||
| ab6db37326 | |||
| eb2ce290a7 | |||
| 8a4c1a7c0a | |||
| 4d3836fb2e | |||
| 75499992eb | |||
| 62643f241b | |||
| 64f606152f | |||
| e2a698e892 | |||
| d1d71dba25 | |||
| bb81fa5f4b | |||
| 03e19893b3 | |||
| 279314e4b7 | |||
| 730912a3f4 | |||
| ff9fb64e75 | |||
| 9cbbe74051 | |||
| 2e1c71c1ab | |||
| f6ca713a6e | |||
| 197cd8e1e0 | |||
| 681b440381 | |||
| 3daeac9e53 | |||
| 7febba2d9c | |||
| 0a3a53763c | |||
| 85a60a2dc7 | |||
| e74e73a3a0 |
@@ -25,6 +25,7 @@ jobs:
|
||||
token: ${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
config-file: release-please-config.json
|
||||
manifest-file: .release-please-manifest.json
|
||||
target-branch: stable
|
||||
|
||||
# Auto-approve + auto-merge the open stable release PR. See the beta
|
||||
# workflow for the full rationale. Skipped on the release-cutting run and
|
||||
|
||||
@@ -17,9 +17,9 @@ name: Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, beta]
|
||||
branches: [main, beta, stable]
|
||||
pull_request:
|
||||
branches: [main, beta]
|
||||
branches: [main, beta, stable]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
|
||||
@@ -1,3 +1 @@
|
||||
{
|
||||
".": "2.6.1"
|
||||
}
|
||||
{".":"3.45.2"}
|
||||
|
||||
+832
-1054
File diff suppressed because it is too large
Load Diff
+8
-1
@@ -27,8 +27,15 @@ FROM node:22-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Redeclare CACHEBUST — ARGs don't cross stage boundaries, so the builder
|
||||
# stage's declaration never reached this stage. Consuming it in the RUN below
|
||||
# busts that layer's cache every CI run (CACHEBUST=github.run_number), so the
|
||||
# image always picks up current Alpine security updates instead of reusing a
|
||||
# stale cached upgrade layer.
|
||||
ARG CACHEBUST=1
|
||||
|
||||
# Upgrade all packages to fix security vulnerabilities (OpenSSL, libexpat, BusyBox CVEs)
|
||||
RUN apk upgrade --no-cache
|
||||
RUN echo "cachebust=${CACHEBUST}" && apk upgrade --no-cache
|
||||
|
||||
# Upgrade the npm CLI in the final image so its bundled deps are patched
|
||||
# (sigstore 4.x, tar) — closes CVE-2026-48815 and the older @sigstore/core / tar
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Regression test for GHSA-9hmx-68vc-qpqw — share-link login must not bypass
|
||||
* the gallery password.
|
||||
*
|
||||
* POST /auth/gallery/share-login validates only the share token. For a
|
||||
* password-protected gallery it previously minted a full `type:'gallery'`
|
||||
* access token on the share token alone, letting anyone holding the share URL
|
||||
* read the gallery without the password. The fix: when the gallery requires a
|
||||
* password, return `{ requires_password: true }` with NO token and NO cookie.
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
|
||||
process.env.JWT_SECRET = 'share-login-test-secret';
|
||||
|
||||
const events = [];
|
||||
|
||||
jest.mock('../../src/database/db', () => {
|
||||
function dbFn(table) {
|
||||
if (table === 'events') {
|
||||
let filter = () => true;
|
||||
return {
|
||||
where(criteria) {
|
||||
filter = (row) => Object.entries(criteria).every(([k, v]) => {
|
||||
if (k === 'is_active') return Boolean(row.is_active) === Boolean(v);
|
||||
if (k === 'is_archived') return Boolean(row.is_archived) === Boolean(v);
|
||||
return row[k] === v;
|
||||
});
|
||||
return this;
|
||||
},
|
||||
async first() { return events.find(filter); },
|
||||
};
|
||||
}
|
||||
return { where() { return this; }, async first() { return undefined; } };
|
||||
}
|
||||
dbFn.raw = async () => {};
|
||||
return { db: dbFn, logActivity: async () => {} };
|
||||
});
|
||||
|
||||
// Share token is stored plainly on the fake event row.
|
||||
jest.mock('../../src/services/shareLinkService', () => ({
|
||||
getEventShareToken: (event) => event.share_token,
|
||||
resolveShareIdentifier: async () => ({ event: null }),
|
||||
}));
|
||||
|
||||
const mockSetGalleryAuthCookies = jest.fn();
|
||||
jest.mock('../../src/utils/tokenUtils', () => ({
|
||||
setGalleryAuthCookies: (...args) => mockSetGalleryAuthCookies(...args),
|
||||
clearGalleryAuthCookies: jest.fn(),
|
||||
getGalleryTokenFromRequest: jest.fn(),
|
||||
setAdminAuthCookies: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/utils/authSecurity', () => ({
|
||||
trackFailedAttempt: jest.fn(async () => {}),
|
||||
trackSuccessfulLogin: jest.fn(async () => {}),
|
||||
checkAccountLockout: jest.fn(async () => ({ isLocked: false })),
|
||||
resetLockout: jest.fn(async () => {}),
|
||||
}));
|
||||
|
||||
// Collaborators the router imports at load but the share-login path doesn't hit.
|
||||
jest.mock('../../src/services/recaptcha', () => ({ verifyRecaptcha: async () => true }));
|
||||
jest.mock('../../src/services/mfaService', () => ({}));
|
||||
jest.mock('../../src/middleware/sessionTimeout', () => ({ endSession: jest.fn(), sessionTimeoutMiddleware: (req, res, next) => next() }));
|
||||
jest.mock('../../src/utils/tokenRevocation', () => ({ revokeToken: jest.fn(async () => {}), isTokenRevoked: async () => false }));
|
||||
|
||||
const authRouter = require('../../src/routes/auth');
|
||||
|
||||
function makeApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use('/auth', authRouter);
|
||||
return app;
|
||||
}
|
||||
|
||||
const SHARE_TOKEN = 'a'.repeat(64);
|
||||
|
||||
beforeEach(() => {
|
||||
events.length = 0;
|
||||
mockSetGalleryAuthCookies.mockClear();
|
||||
});
|
||||
|
||||
describe('POST /auth/gallery/share-login password enforcement', () => {
|
||||
it('does NOT mint a token for a password-protected gallery', async () => {
|
||||
events.push({
|
||||
id: 1, slug: 'private-gallery', is_active: 1, is_archived: 0,
|
||||
require_password: 1, share_token: SHARE_TOKEN, event_name: 'Private',
|
||||
});
|
||||
const res = await request(makeApp())
|
||||
.post('/auth/gallery/share-login')
|
||||
.send({ slug: 'private-gallery', token: SHARE_TOKEN });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.requires_password).toBe(true);
|
||||
expect(res.body.token).toBeUndefined();
|
||||
expect(mockSetGalleryAuthCookies).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('mints a token for a public (no-password) gallery', async () => {
|
||||
events.push({
|
||||
id: 2, slug: 'public-gallery', is_active: 1, is_archived: 0,
|
||||
require_password: false, share_token: SHARE_TOKEN, event_name: 'Public',
|
||||
});
|
||||
const res = await request(makeApp())
|
||||
.post('/auth/gallery/share-login')
|
||||
.send({ slug: 'public-gallery', token: SHARE_TOKEN });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(typeof res.body.token).toBe('string');
|
||||
expect(res.body.event).toBeDefined();
|
||||
expect(mockSetGalleryAuthCookies).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('rejects a wrong share token regardless of password setting', async () => {
|
||||
events.push({
|
||||
id: 3, slug: 'public-gallery', is_active: 1, is_archived: 0,
|
||||
require_password: false, share_token: SHARE_TOKEN, event_name: 'Public',
|
||||
});
|
||||
const res = await request(makeApp())
|
||||
.post('/auth/gallery/share-login')
|
||||
.send({ slug: 'public-gallery', token: 'b'.repeat(64) });
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
expect(mockSetGalleryAuthCookies).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Regression test for GHSA-4j34-x562-5vfq — broken access control in the legacy
|
||||
* /api/events router.
|
||||
*
|
||||
* The legacy router exposed create/list/update/delete/extend guarded by
|
||||
* adminAuth ALONE (no requirePermission, no requireEventOwnership), so any
|
||||
* back-office account — down to a read-only viewer — could read every gallery's
|
||||
* password_hash/share_token and take over any gallery. The fix removes that
|
||||
* router entirely and migrates its one UI-used route (POST /:id/extend) to the
|
||||
* canonical /api/admin/events mount, where it inherits the permission +
|
||||
* ownership guards.
|
||||
*
|
||||
* This test pins two invariants:
|
||||
* 1. The legacy source file is gone (nothing can re-mount it).
|
||||
* 2. The migrated extend route enforces ownership — a non-owning editor gets
|
||||
* 403, the owner succeeds.
|
||||
*/
|
||||
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-legacy-acl-')), 'db.sqlite'
|
||||
);
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'legacy-acl-test-secret';
|
||||
|
||||
const express = require('express');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const request = require('supertest');
|
||||
const { bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken } = require('../integration/helpers/crmDb');
|
||||
|
||||
async function insertEvent(db, ownerId, over = {}) {
|
||||
const base = {
|
||||
slug: `ev-${Math.random().toString(16).slice(2)}`,
|
||||
event_type: 'wedding',
|
||||
event_name: 'Owner Gallery',
|
||||
event_date: '2026-05-29',
|
||||
host_email: 'host@example.com',
|
||||
admin_email: 'admin@example.com',
|
||||
password_hash: 'x',
|
||||
share_link: `/gallery/share-${Math.random().toString(16).slice(2)}`,
|
||||
share_token: `st-${Math.random().toString(16).slice(2)}`,
|
||||
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
|
||||
is_active: 1, is_archived: 0, is_draft: 0,
|
||||
created_by: ownerId,
|
||||
created_at: new Date().toISOString(),
|
||||
...over,
|
||||
};
|
||||
const r = await db('events').insert(base).returning('id');
|
||||
return r[0]?.id ?? r[0];
|
||||
}
|
||||
|
||||
describe('GHSA-4j34: legacy /api/events router removed + extend guarded', () => {
|
||||
it('the legacy events router source file no longer exists', () => {
|
||||
expect(fs.existsSync(path.join(__dirname, '../../src/routes/events.js'))).toBe(false);
|
||||
});
|
||||
|
||||
describe('POST /api/admin/events/:id/extend ownership enforcement', () => {
|
||||
let db; let cleanup; let app;
|
||||
let ownerId; let ownerToken;
|
||||
let editorId; let editorToken;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
({ adminId: ownerId } = await seedMinimal(db));
|
||||
await assignAdminRole(db, ownerId, 'super_admin');
|
||||
ownerToken = mintAdminToken(ownerId);
|
||||
|
||||
// A second, non-owning account with the low-trust editor role.
|
||||
[editorId] = await db('admin_users').insert({
|
||||
username: 'editor1', email: 'editor1@example.com',
|
||||
password_hash: 'x', is_active: 1,
|
||||
}).returning('id');
|
||||
editorId = editorId?.id ?? editorId;
|
||||
await assignAdminRole(db, editorId, 'editor');
|
||||
editorToken = mintAdminToken(editorId);
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use(cookieParser());
|
||||
app.use('/api/admin/events', require('../../src/routes/adminEvents'));
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
app.use((err, req, res, next) => {
|
||||
res.status(err.statusCode || err.status || 500).json({ error: err.message, code: err.code });
|
||||
});
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { await cleanup(); });
|
||||
|
||||
it('lets the owner extend their own gallery', async () => {
|
||||
const id = await insertEvent(db, ownerId, { expires_at: '2026-06-01T00:00:00.000Z' });
|
||||
const res = await request(app)
|
||||
.post(`/api/admin/events/${id}/extend`)
|
||||
.set('Authorization', `Bearer ${ownerToken}`)
|
||||
.send({ days: 10 });
|
||||
expect(res.status).toBe(200);
|
||||
expect(new Date(res.body.expires_at).toISOString()).toBe('2026-06-11T00:00:00.000Z');
|
||||
});
|
||||
|
||||
it('403s a non-owning editor trying to extend someone else\'s gallery', async () => {
|
||||
const id = await insertEvent(db, ownerId); // owned by the super_admin
|
||||
const res = await request(app)
|
||||
.post(`/api/admin/events/${id}/extend`)
|
||||
.set('Authorization', `Bearer ${editorToken}`)
|
||||
.send({ days: 30 });
|
||||
expect(res.status).toBe(403); // requireEventOwnership blocks it
|
||||
});
|
||||
|
||||
it('validates the days field', async () => {
|
||||
const id = await insertEvent(db, ownerId);
|
||||
const res = await request(app)
|
||||
.post(`/api/admin/events/${id}/extend`)
|
||||
.set('Authorization', `Bearer ${ownerToken}`)
|
||||
.send({ days: 9999 });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const fs = require('fs').promises;
|
||||
|
||||
// Point storage at a throwaway temp dir before requiring the service so the
|
||||
// module-level getStoragePath() picks it up if evaluated.
|
||||
process.env.STORAGE_PATH = path.join(os.tmpdir(), `picpeak-chunk-test-${process.pid}`);
|
||||
|
||||
const chunkedUpload = require('../../src/services/chunkedUploadService');
|
||||
|
||||
describe('chunkedUploadService.initializeUpload filename sanitisation (GHSA-pc72-jf53-w28j)', () => {
|
||||
afterAll(async () => {
|
||||
await fs.rm(process.env.STORAGE_PATH, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
it('strips directory-traversal components from the stored filename', async () => {
|
||||
const { uploadId } = await chunkedUpload.initializeUpload({
|
||||
filename: '../../uploads/logos/evil.svg',
|
||||
fileSize: 10,
|
||||
mimeType: 'video/mp4',
|
||||
eventId: 1,
|
||||
totalChunks: 1,
|
||||
});
|
||||
const meta = chunkedUpload.getUploadStatus(uploadId);
|
||||
// basename('../../uploads/logos/evil.svg') === 'evil.svg' — the traversal
|
||||
// is gone, so path.join(tempDir, filename) can no longer escape tempDir.
|
||||
expect(meta.filename).toBe('evil.svg');
|
||||
});
|
||||
|
||||
it('keeps a normal filename intact', async () => {
|
||||
const { uploadId } = await chunkedUpload.initializeUpload({
|
||||
filename: 'clip.mp4',
|
||||
fileSize: 10,
|
||||
mimeType: 'video/mp4',
|
||||
eventId: 1,
|
||||
totalChunks: 1,
|
||||
});
|
||||
expect(uploadId).toBeTruthy();
|
||||
});
|
||||
|
||||
it('rejects a filename that collapses to nothing', async () => {
|
||||
await expect(
|
||||
chunkedUpload.initializeUpload({
|
||||
filename: '../',
|
||||
fileSize: 10,
|
||||
mimeType: 'video/mp4',
|
||||
eventId: 1,
|
||||
totalChunks: 1,
|
||||
})
|
||||
).rejects.toThrow(/Invalid filename/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Regression tests for reinjectCurrentAdmin — the operator-preservation step of
|
||||
* the .picpeak restore (GHSA-qxfx-4493-4v8f follow-up). Runs against a real
|
||||
* in-memory SQLite DB so the UNIQUE(email)/UNIQUE(username) constraints behave
|
||||
* as in production. Reconciliation is non-destructive (update-in-place / rename,
|
||||
* never delete) so restored rows referenced by FKs keep their ids.
|
||||
*/
|
||||
const knex = require('knex');
|
||||
|
||||
let db;
|
||||
let reinjectCurrentAdmin;
|
||||
|
||||
beforeAll(() => {
|
||||
jest.doMock('../../knexfile', () => ({ client: 'sqlite3' }), { virtual: false });
|
||||
reinjectCurrentAdmin = require('../../src/services/picpeakImportService').reinjectCurrentAdmin;
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
db = knex({ client: 'sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true });
|
||||
await db.schema.createTable('admin_users', (t) => {
|
||||
t.increments('id');
|
||||
t.string('username').notNullable().unique();
|
||||
t.string('email').notNullable().unique();
|
||||
t.string('password_hash');
|
||||
t.boolean('is_active').defaultTo(true);
|
||||
t.boolean('must_change_password').defaultTo(false);
|
||||
t.integer('role_id');
|
||||
t.integer('created_by');
|
||||
t.boolean('two_factor_enabled').defaultTo(false);
|
||||
t.string('two_factor_secret');
|
||||
t.text('two_factor_recovery_codes');
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => { await db.destroy(); });
|
||||
|
||||
const operator = {
|
||||
id: 1, username: 'admin', email: 'op@example.com',
|
||||
password_hash: 'OP_HASH', is_active: 1, must_change_password: 0, role_id: 1, created_by: 99,
|
||||
two_factor_enabled: 1, two_factor_secret: 'OP_SECRET', two_factor_recovery_codes: '["a","b"]',
|
||||
};
|
||||
|
||||
test('restores login + MFA in place, keeping the row id and its FK columns (FK-safe)', async () => {
|
||||
await db('admin_users').insert({
|
||||
id: 7, username: 'someoneelse', email: 'OP@example.com',
|
||||
password_hash: 'ATTACKER', is_active: 1, must_change_password: 0, role_id: 4, created_by: 5,
|
||||
two_factor_enabled: 0, two_factor_secret: 'ATTACKER_SECRET', two_factor_recovery_codes: null,
|
||||
});
|
||||
await db.transaction((trx) => reinjectCurrentAdmin(trx, operator));
|
||||
|
||||
const rows = await db('admin_users');
|
||||
expect(rows).toHaveLength(1);
|
||||
const row = rows[0];
|
||||
expect(row.id).toBe(7); // id preserved → FK refs hold
|
||||
expect(row.username).toBe('admin');
|
||||
expect(row.password_hash).toBe('OP_HASH');
|
||||
expect(Boolean(row.two_factor_enabled)).toBe(true);
|
||||
expect(row.two_factor_secret).toBe('OP_SECRET'); // attacker MFA secret gone
|
||||
expect(row.two_factor_recovery_codes).toBe('["a","b"]');
|
||||
// Relationship/audit FKs are NOT forced from the operator snapshot (avoids
|
||||
// dangling role_id/created_by on a cross-instance restore) — the restored
|
||||
// row keeps its own already-valid values.
|
||||
expect(row.role_id).toBe(4);
|
||||
expect(row.created_by).toBe(5);
|
||||
});
|
||||
|
||||
test('renames (not deletes) a different row holding the operator username', async () => {
|
||||
await db('admin_users').insert({
|
||||
id: 3, username: 'admin', email: 'other@instance.test',
|
||||
password_hash: 'OTHER', is_active: 1, role_id: 4,
|
||||
});
|
||||
await expect(db.transaction((trx) => reinjectCurrentAdmin(trx, operator))).resolves.not.toThrow();
|
||||
|
||||
const rows = await db('admin_users').orderBy('id');
|
||||
expect(rows).toHaveLength(2); // the other admin survives (FK-safe)
|
||||
const other = rows.find((r) => r.id === 3);
|
||||
expect(other.username).toBe('admin__restored_3'); // renamed, id kept
|
||||
expect(other.email).toBe('other@instance.test');
|
||||
const op = rows.find((r) => r.username === 'admin');
|
||||
expect(op.password_hash).toBe('OP_HASH');
|
||||
});
|
||||
|
||||
test('reconciles email and username colliding with DIFFERENT rows without deleting either', async () => {
|
||||
await db('admin_users').insert([
|
||||
{ id: 4, username: 'someoneelse', email: 'op@example.com', password_hash: 'A', role_id: 4 },
|
||||
{ id: 5, username: 'admin', email: 'other@instance.test', password_hash: 'B', role_id: 4 },
|
||||
]);
|
||||
await expect(db.transaction((trx) => reinjectCurrentAdmin(trx, operator))).resolves.not.toThrow();
|
||||
|
||||
const rows = await db('admin_users').orderBy('id');
|
||||
expect(rows).toHaveLength(2); // both rows survive
|
||||
const opRow = rows.find((r) => r.id === 4); // email match updated in place
|
||||
expect(opRow.username).toBe('admin');
|
||||
expect(opRow.password_hash).toBe('OP_HASH');
|
||||
const renamed = rows.find((r) => r.id === 5); // username holder renamed, not deleted
|
||||
expect(renamed.username).toBe('admin__restored_5');
|
||||
});
|
||||
|
||||
test('inserts the operator with a non-colliding id when neither key exists in the backup', async () => {
|
||||
await db('admin_users').insert({
|
||||
id: 9, username: 'backupadmin', email: 'backup@instance.test', password_hash: 'B', role_id: 1,
|
||||
});
|
||||
await db.transaction((trx) => reinjectCurrentAdmin(trx, operator));
|
||||
|
||||
const rows = await db('admin_users').orderBy('id');
|
||||
expect(rows).toHaveLength(2); // backup admin untouched
|
||||
const opRow = rows.find((r) => r.username === 'admin');
|
||||
expect(opRow.password_hash).toBe('OP_HASH');
|
||||
expect(opRow.id).toBe(10); // max(9)+1, no collision
|
||||
expect(opRow.created_by).toBeNull(); // self-ref FK nulled so the insert can't dangle
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
const path = require('path');
|
||||
const { assertZipEntriesWithin } = require('../../src/utils/safePath');
|
||||
|
||||
describe('assertZipEntriesWithin (ZIP-slip guard, GHSA-jfhw-fj23-fx6x)', () => {
|
||||
const root = path.join('/tmp', 'picpeak-extract-root');
|
||||
|
||||
it('accepts entries that stay within the extraction root', () => {
|
||||
const entries = [
|
||||
{ name: 'photo.jpg' },
|
||||
{ name: 'category/nested/photo.png' },
|
||||
{ name: 'photos_manifest.json' },
|
||||
{ name: 'subdir/' },
|
||||
];
|
||||
expect(() => assertZipEntriesWithin(entries, root)).not.toThrow();
|
||||
});
|
||||
|
||||
it('rejects a parent-traversal entry', () => {
|
||||
const entries = [{ name: '../../uploads/logos/evil.svg' }];
|
||||
expect(() => assertZipEntriesWithin(entries, root)).toThrow(/escapes the extraction directory/);
|
||||
});
|
||||
|
||||
it('rejects an absolute-path entry', () => {
|
||||
const entries = [{ name: '/etc/cron.d/evil' }];
|
||||
expect(() => assertZipEntriesWithin(entries, root)).toThrow(/escapes the extraction directory/);
|
||||
});
|
||||
|
||||
it('rejects when a safe entry is mixed with a traversal entry', () => {
|
||||
const entries = [{ name: 'ok.jpg' }, { name: '../escape.txt' }];
|
||||
expect(() => assertZipEntriesWithin(entries, root)).toThrow(/escapes the extraction directory/);
|
||||
});
|
||||
|
||||
it('tolerates empty / nameless entries', () => {
|
||||
expect(() => assertZipEntriesWithin([{}, { name: '' }, null], root)).not.toThrow();
|
||||
});
|
||||
|
||||
it('does not treat a sibling prefix directory as inside the root', () => {
|
||||
// root is .../picpeak-extract-root; ../picpeak-extract-root-evil must not pass
|
||||
const entries = [{ name: '../picpeak-extract-root-evil/x' }];
|
||||
expect(() => assertZipEntriesWithin(entries, root)).toThrow(/escapes the extraction directory/);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "picpeak-backend",
|
||||
"version": "3.83.0-beta.0",
|
||||
"version": "3.45.2",
|
||||
"description": "Backend for PicPeak event photo sharing platform",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
|
||||
+1
-3
@@ -38,7 +38,6 @@ const {
|
||||
|
||||
// Import routes
|
||||
const authRoutes = require('./src/routes/auth');
|
||||
const eventRoutes = require('./src/routes/events');
|
||||
const galleryRoutes = require('./src/routes/gallery');
|
||||
const adminRoutes = require('./src/routes/admin');
|
||||
const adminAuthRoutes = require('./src/routes/adminAuth');
|
||||
@@ -695,8 +694,7 @@ app.get('/health', async (req, res) => {
|
||||
// Routes
|
||||
app.use('/api/setup', setupRoutes); // public first-run bootstrap (self-closes after setup)
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/events', eventRoutes);
|
||||
app.use('/api/admin/external-media', require('./src/routes/adminExternalMedia'));
|
||||
app.use('/api/admin/external-media', require('./src/routes/adminExternalMedia'));
|
||||
// Gallery routes - main routes first, then feedback routes
|
||||
app.use('/api/gallery', galleryRoutes);
|
||||
app.use('/api/gallery', require('./src/routes/galleryFeedback'));
|
||||
|
||||
@@ -9,6 +9,7 @@ const { requirePermission } = require('../middleware/permissions');
|
||||
const archiver = require('archiver');
|
||||
const StreamZip = require('node-stream-zip');
|
||||
const { requireEventOwnership } = require('../middleware/ownership');
|
||||
const { assertZipEntriesWithin } = require('../utils/safePath');
|
||||
const logger = require('../utils/logger');
|
||||
const { getPagination } = require('../utils/routeHelpers');
|
||||
const router = express.Router();
|
||||
@@ -183,6 +184,16 @@ router.post('/:id/restore', adminAuth, requirePermission('archives.restore'), re
|
||||
const entries = Object.values(await zip.entries());
|
||||
logger.info(`Archive contains ${entries.length} entries`);
|
||||
|
||||
// Reject ZIP-slip entries before writing anything to disk — extract()
|
||||
// does not neutralise `../` in entry names (GHSA-jfhw-fj23-fx6x).
|
||||
try {
|
||||
assertZipEntriesWithin(entries, eventDir);
|
||||
} catch (slipErr) {
|
||||
await zip.close();
|
||||
logger.warn(`Refusing archive restore — unsafe entry path: ${slipErr.message}`);
|
||||
return res.status(400).json({ error: 'Archive contains invalid entry paths' });
|
||||
}
|
||||
|
||||
// Stream-extract everything to disk
|
||||
await zip.extract(null, eventDir);
|
||||
await zip.close();
|
||||
|
||||
@@ -2,6 +2,8 @@ const express = require('express');
|
||||
const { db } = require('../database/db');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const { clearAdminAuthCookie } = require('../utils/tokenUtils');
|
||||
const { revokeToken } = require('../utils/tokenRevocation');
|
||||
const { triggerManualBackup, getBackupStatus, cleanupOldBackupRuns, getBackupManifest, validateBackupManifest } = require('../services/backupService');
|
||||
const logger = require('../utils/logger');
|
||||
const { errorResponse, getPagination } = require('../utils/routeHelpers');
|
||||
@@ -178,12 +180,43 @@ router.post('/picpeak/import', adminAuth, requirePermission('backup.restore'), p
|
||||
const picpeakPath = req.file.path;
|
||||
try {
|
||||
const { importFromPicpeak } = require('../services/picpeakImportService');
|
||||
const result = await importFromPicpeak({ picpeakPath, currentAdminId: req.user && req.user.id });
|
||||
// adminAuth populates req.admin, not req.user. Passing req.user.id here
|
||||
// left currentAdminId undefined, so reinjectCurrentAdmin() had no account
|
||||
// to preserve and the admin_users table was fully replaced by the backup —
|
||||
// letting a crafted .picpeak take over every admin account (GHSA-qxfx-4493-4v8f).
|
||||
const result = await importFromPicpeak({ picpeakPath, currentAdminId: req.admin && req.admin.id });
|
||||
|
||||
// The restore rewrote admin_users, so ids may have shifted. The operator's
|
||||
// current JWT is bound only to the pre-restore admin id (adminAuth trusts
|
||||
// `decoded.id` — IP is logged, not enforced, and the backup controls
|
||||
// password_changed_at), which could now resolve to a DIFFERENT restored
|
||||
// account and silently grant its permissions. Force a fresh login instead
|
||||
// of trusting the old session: revoke the token and clear the cookie.
|
||||
// Clearing the cookie is the guarantee — it drops the operator's browser
|
||||
// session unconditionally. Revocation is the extra layer that also kills a
|
||||
// Bearer-header copy of the JWT; revokeToken() swallows DB errors and
|
||||
// returns false, so check the result and log loudly if the denylist write
|
||||
// didn't land (the operator should still re-login, which the cookie clear
|
||||
// forces).
|
||||
let tokenRevoked = false;
|
||||
try {
|
||||
if (req.token) {
|
||||
tokenRevoked = await revokeToken(req.token, 'picpeak-import', { adminId: req.admin && req.admin.id });
|
||||
}
|
||||
} catch (revokeErr) {
|
||||
logger.warn('[picpeak-import] failed to revoke session token after restore', { error: revokeErr.message });
|
||||
}
|
||||
if (req.token && !tokenRevoked) {
|
||||
logger.warn('[picpeak-import] session token was NOT added to the revocation denylist after restore; relying on cookie clear to force re-login');
|
||||
}
|
||||
clearAdminAuthCookie(res);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
tables: result.tables,
|
||||
filesRestored: result.filesRestored,
|
||||
usesExternalMedia: result.usesExternalMedia,
|
||||
sessionInvalidated: true,
|
||||
});
|
||||
} catch (error) {
|
||||
const status = error.statusCode || 500;
|
||||
|
||||
@@ -1596,4 +1596,51 @@ module.exports = (router) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Extend a gallery's expiration. Migrated from the legacy /api/events router
|
||||
// (removed — GHSA-4j34-x562-5vfq), now on the canonical mount with the same
|
||||
// permission + ownership guards as every other gallery mutation, so a
|
||||
// non-owning editor/viewer can no longer touch a gallery they don't own.
|
||||
router.post('/:id/extend', adminAuth, requirePermission('events.edit'), requireEventOwnership, [
|
||||
body('days').isInt({ min: 1, max: 365 })
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
const { days } = req.body;
|
||||
|
||||
let eventQuery = db('events').where('id', id);
|
||||
// Editor role can only touch their own events (defence in depth alongside
|
||||
// requireEventOwnership).
|
||||
if (req.admin.roleName === 'editor') {
|
||||
eventQuery = eventQuery.where('created_by', req.admin.id);
|
||||
}
|
||||
const event = await eventQuery.first();
|
||||
if (!event) {
|
||||
return res.status(404).json({ error: 'Event not found' });
|
||||
}
|
||||
|
||||
const newExpiration = new Date(event.expires_at);
|
||||
newExpiration.setDate(newExpiration.getDate() + days);
|
||||
|
||||
await db('events').where('id', id).update({
|
||||
expires_at: newExpiration,
|
||||
is_active: formatBoolean(true) // reactivate if it had expired
|
||||
});
|
||||
|
||||
await logActivity('event_expiration_extended',
|
||||
{ eventName: event.event_name, days },
|
||||
id,
|
||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||
);
|
||||
|
||||
res.json({ expires_at: newExpiration });
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to extend expiration');
|
||||
}
|
||||
});
|
||||
|
||||
};
|
||||
|
||||
@@ -543,6 +543,18 @@ router.post('/gallery/share-login', [
|
||||
return res.status(401).json({ error: 'Invalid or expired share link' });
|
||||
}
|
||||
|
||||
const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0');
|
||||
|
||||
// The share link only proves the holder was given the link — it is NOT the
|
||||
// gallery password. For a password-protected gallery, minting a full
|
||||
// `type:'gallery'` token here would let anyone with the share URL bypass
|
||||
// the password entirely (GHSA-9hmx-68vc-qpqw). Signal that a password is
|
||||
// still required and return WITHOUT a token/cookie; the client then goes
|
||||
// through POST /gallery/verify, which does check the password.
|
||||
if (requiresPassword) {
|
||||
return res.json({ requires_password: true });
|
||||
}
|
||||
|
||||
const jwtToken = jwt.sign({
|
||||
eventId: event.id,
|
||||
eventSlug: event.slug,
|
||||
@@ -557,8 +569,6 @@ router.post('/gallery/share-login', [
|
||||
await trackSuccessfulLogin(`gallery:${event.slug}:share`, ipAddress, userAgent);
|
||||
setGalleryAuthCookies(res, jwtToken, event.slug);
|
||||
|
||||
const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0');
|
||||
|
||||
res.json({
|
||||
token: jwtToken,
|
||||
event: {
|
||||
|
||||
@@ -1,443 +0,0 @@
|
||||
const express = require('express');
|
||||
const { body, validationResult } = require('express-validator');
|
||||
const bcrypt = require('bcrypt');
|
||||
const crypto = require('crypto');
|
||||
const { db } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { slugify } = require('../utils/slug');
|
||||
const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
const router = express.Router();
|
||||
const { buildShareLinkVariants } = require('../services/shareLinkService');
|
||||
const { parseBooleanInput, parseStringInput } = require('../utils/parsers');
|
||||
const eventTypeService = require('../services/eventTypeService');
|
||||
const { IDENTITY_PRESERVING_NORMALIZE_EMAIL } = require('../utils/emailNormalization');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
// Use parseStringInput from shared parsers for customer data extraction
|
||||
const getCustomerNameFromPayload = (payload = {}) => parseStringInput(payload.customer_name);
|
||||
const getCustomerEmailFromPayload = (payload = {}) => parseStringInput(payload.customer_email);
|
||||
const getCustomerPhoneFromPayload = (payload = {}) => parseStringInput(payload.customer_phone);
|
||||
|
||||
// Whether the global "phone field" toggle (#322) is enabled. Same shape as
|
||||
// the helper in adminEvents.js — kept local so this route doesn't import
|
||||
// from a sibling route file.
|
||||
const isPhoneFieldEnabled = async () => {
|
||||
try {
|
||||
const row = await db('app_settings').where('setting_key', 'event_phone_field_enabled').first();
|
||||
if (!row) return false;
|
||||
let value = row.setting_value;
|
||||
if (typeof value === 'string') {
|
||||
try { value = JSON.parse(value); } catch { /* keep raw */ }
|
||||
}
|
||||
return value === true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const mapEventForApi = (event) => {
|
||||
if (!event || typeof event !== 'object') {
|
||||
return event;
|
||||
}
|
||||
|
||||
const {
|
||||
host_name,
|
||||
host_email,
|
||||
customer_name,
|
||||
customer_email,
|
||||
...rest
|
||||
} = event;
|
||||
|
||||
return {
|
||||
...rest,
|
||||
customer_name: customer_name ?? host_name ?? null,
|
||||
customer_email: customer_email ?? host_email ?? null
|
||||
};
|
||||
};
|
||||
|
||||
let customerColumnCache = null;
|
||||
const hasCustomerContactColumns = async () => {
|
||||
if (customerColumnCache === true) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const hasColumn = await db.schema.hasColumn('events', 'customer_email');
|
||||
if (hasColumn) {
|
||||
customerColumnCache = true;
|
||||
}
|
||||
return hasColumn;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Create new event
|
||||
router.post('/', adminAuth, [
|
||||
body('event_type').notEmpty().trim().custom(async (value) => {
|
||||
const isValid = await eventTypeService.isValidEventType(value);
|
||||
if (!isValid) {
|
||||
throw new Error('Invalid event type');
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
body('event_name').notEmpty(),
|
||||
body('event_date').isDate(),
|
||||
body('customer_name').notEmpty().trim(),
|
||||
body('customer_email').isEmail().normalizeEmail(IDENTITY_PRESERVING_NORMALIZE_EMAIL),
|
||||
body('customer_phone').optional({ nullable: true, checkFalsy: true })
|
||||
.isString().trim()
|
||||
.isLength({ max: 32 }).withMessage('Phone number must be at most 32 characters'),
|
||||
body('admin_email').isEmail(),
|
||||
body('require_password').optional().isBoolean(),
|
||||
body('password').optional().isString().custom((value, { req }) => {
|
||||
const requirePassword = parseBooleanInput(req.body.require_password, true);
|
||||
if (!requirePassword) {
|
||||
return true;
|
||||
}
|
||||
if (typeof value !== 'string' || value.trim().length < 6) {
|
||||
throw new Error('Password must be at least 6 characters long');
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
body('expiration_days').isInt({ min: 1, max: 365 }).optional()
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const {
|
||||
event_type,
|
||||
event_name,
|
||||
event_date,
|
||||
admin_email,
|
||||
password,
|
||||
require_password: requirePasswordInput = true,
|
||||
welcome_message,
|
||||
color_theme,
|
||||
expiration_days = 30
|
||||
} = req.body;
|
||||
|
||||
const customerEmail = getCustomerEmailFromPayload(req.body);
|
||||
const customerName = getCustomerNameFromPayload(req.body);
|
||||
|
||||
if (!customerName || !customerEmail) {
|
||||
return res.status(400).json({ error: 'customer_name and customer_email are required' });
|
||||
}
|
||||
|
||||
const customerColumnsAvailable = await hasCustomerContactColumns();
|
||||
const phoneEnabled = await isPhoneFieldEnabled();
|
||||
const customerPhone = phoneEnabled ? getCustomerPhoneFromPayload(req.body) : null;
|
||||
|
||||
const requirePassword = parseBooleanInput(requirePasswordInput, true);
|
||||
|
||||
if (requirePassword) {
|
||||
const passwordValidation = await validatePasswordInContext(password, 'gallery', {
|
||||
eventName: event_name
|
||||
});
|
||||
|
||||
if (!passwordValidation.valid) {
|
||||
return res.status(400).json({
|
||||
error: 'Password does not meet security requirements',
|
||||
details: passwordValidation.errors,
|
||||
score: passwordValidation.score,
|
||||
feedback: passwordValidation.feedback
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Generate unique slug — slugify() handles accents (see #525).
|
||||
const baseSlug = `${event_type}-${slugify(event_name)}-${event_date}`;
|
||||
let slug = baseSlug;
|
||||
let counter = 1;
|
||||
|
||||
while (await db('events').where({ slug }).first()) {
|
||||
slug = `${baseSlug}-${counter}`;
|
||||
counter++;
|
||||
}
|
||||
|
||||
// Generate share link variants (auto-detects short URL preference)
|
||||
const shareToken = crypto.randomBytes(16).toString('hex');
|
||||
const { sharePath, shareUrl, shareLinkToStore } = await buildShareLinkVariants({ slug, shareToken });
|
||||
|
||||
// Hash password (or placeholder when not required)
|
||||
const password_hash = requirePassword
|
||||
? await bcrypt.hash(password, getBcryptRounds())
|
||||
: await bcrypt.hash(crypto.randomBytes(32).toString('hex'), getBcryptRounds());
|
||||
|
||||
// Calculate expiration date (days after event date)
|
||||
const expires_at = new Date(event_date);
|
||||
expires_at.setDate(expires_at.getDate() + parseInt(expiration_days, 10));
|
||||
|
||||
// Create folder structure
|
||||
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
const eventPath = path.join(storagePath, 'events/active', slug);
|
||||
await fs.mkdir(path.join(eventPath, 'collages'), { recursive: true });
|
||||
await fs.mkdir(path.join(eventPath, 'individual'), { recursive: true });
|
||||
|
||||
// Insert into database
|
||||
const insertResult = await db('events').insert({
|
||||
slug,
|
||||
event_type,
|
||||
event_name,
|
||||
event_date,
|
||||
...(customerColumnsAvailable ? { customer_name: customerName, customer_email: customerEmail } : {}),
|
||||
...(customerPhone ? { customer_phone: customerPhone } : {}),
|
||||
host_name: customerName,
|
||||
host_email: customerEmail,
|
||||
admin_email,
|
||||
password_hash,
|
||||
welcome_message,
|
||||
color_theme,
|
||||
share_link: shareLinkToStore,
|
||||
share_token: shareToken,
|
||||
expires_at,
|
||||
require_password: formatBoolean(requirePassword)
|
||||
}).returning('id');
|
||||
|
||||
// Handle both PostgreSQL (returns array of objects) and SQLite (returns array of IDs)
|
||||
const eventId = insertResult[0]?.id || insertResult[0];
|
||||
|
||||
// Queue creation email
|
||||
const { queueEmail } = require('../services/emailProcessor');
|
||||
await queueEmail(eventId, customerEmail, 'gallery_created', {
|
||||
customer_name: customerName,
|
||||
customer_email: customerEmail,
|
||||
host_name: customerName,
|
||||
event_name,
|
||||
event_date: event_date, // Pass raw date - will be formatted by email processor
|
||||
gallery_link: shareUrl,
|
||||
gallery_password: requirePassword ? password : 'No password required',
|
||||
expiry_date: expires_at.toISOString(), // Pass ISO string - will be formatted by email processor
|
||||
welcome_message: welcome_message || ''
|
||||
});
|
||||
|
||||
// WhatsApp gallery_ready notification (#647 follow-up). Mirrors the
|
||||
// adminEvents.js path: fires when the customer supplied a phone, the
|
||||
// feature is enabled, and a config exists. Non-fatal — a queue failure
|
||||
// must never block gallery creation.
|
||||
if (customerPhone) {
|
||||
try {
|
||||
const { queueWhatsapp, getWhatsAppConfig } = require('../services/whatsappProcessor');
|
||||
const waConfig = await getWhatsAppConfig();
|
||||
if (waConfig && waConfig.enabled) {
|
||||
await queueWhatsapp(eventId, customerPhone, 'gallery_created', {
|
||||
customer_name: customerName || '',
|
||||
event_name,
|
||||
gallery_link: shareUrl,
|
||||
gallery_password: requirePassword ? password : '',
|
||||
expiry_date: expires_at ? expires_at.toISOString() : null,
|
||||
language: null,
|
||||
});
|
||||
}
|
||||
} catch (waError) {
|
||||
logger.warn('Failed to queue WhatsApp notification on create', waError.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Webhook lifecycle (#327). Legacy public endpoint — events go live
|
||||
// immediately so created + published fire together. Payload uses the
|
||||
// canonical event subject (#341) — every event.* webhook now includes
|
||||
// customer contact + share_token.
|
||||
try {
|
||||
const webhookService = require('../services/webhookService');
|
||||
const eventSubject = webhookService.buildEventSubject({
|
||||
id: eventId,
|
||||
slug,
|
||||
event_name,
|
||||
event_type,
|
||||
event_date,
|
||||
share_url: shareUrl,
|
||||
share_token: shareToken,
|
||||
customer_name: customerName,
|
||||
customer_email: customerEmail,
|
||||
customer_phone: customerPhone,
|
||||
});
|
||||
await webhookService.fire('event.created', { event: eventSubject });
|
||||
await webhookService.fire('event.published', { event: eventSubject });
|
||||
} catch (e) { /* non-fatal */ }
|
||||
|
||||
res.json({
|
||||
id: eventId,
|
||||
slug,
|
||||
share_link: shareUrl,
|
||||
expires_at,
|
||||
require_password: requirePassword,
|
||||
customer_name: customerName,
|
||||
customer_email: customerEmail
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
res.status(500).json({ error: 'Failed to create event' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get all events (admin)
|
||||
router.get('/', adminAuth, async (req, res) => {
|
||||
try {
|
||||
const { status = 'all' } = req.query;
|
||||
|
||||
let query = db('events').select('*');
|
||||
|
||||
if (status === 'active') {
|
||||
query = query.where('is_active', formatBoolean(true));
|
||||
} else if (status === 'archived') {
|
||||
query = query.where('is_archived', formatBoolean(true));
|
||||
}
|
||||
|
||||
const events = await query.orderBy('created_at', 'desc');
|
||||
|
||||
// Add photo counts
|
||||
for (const event of events) {
|
||||
const photoCount = await db('photos').where('event_id', event.id).count('id as count').first();
|
||||
event.photo_count = photoCount.count;
|
||||
}
|
||||
|
||||
res.json(events.map(mapEventForApi));
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to fetch events' });
|
||||
}
|
||||
});
|
||||
|
||||
// Update event
|
||||
router.put('/:id', adminAuth, [
|
||||
body('customer_name').optional().trim().notEmpty(),
|
||||
body('customer_email').optional().isEmail().normalizeEmail(IDENTITY_PRESERVING_NORMALIZE_EMAIL),
|
||||
body('require_password').optional().isBoolean()
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
const updates = { ...req.body };
|
||||
const customerColumnsAvailable = await hasCustomerContactColumns();
|
||||
|
||||
// Don't allow updating certain fields
|
||||
delete updates.id;
|
||||
delete updates.slug;
|
||||
delete updates.created_at;
|
||||
delete updates.password_confirmation;
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(updates, 'host_name') || Object.prototype.hasOwnProperty.call(updates, 'host_email')) {
|
||||
return res.status(400).json({ error: 'host_name and host_email are no longer supported. Use customer_name and customer_email instead.' });
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(updates, 'customer_name')) {
|
||||
const nextName = getCustomerNameFromPayload(updates);
|
||||
if (nextName) {
|
||||
if (customerColumnsAvailable) {
|
||||
updates.customer_name = nextName;
|
||||
} else {
|
||||
delete updates.customer_name;
|
||||
}
|
||||
updates.host_name = nextName;
|
||||
} else {
|
||||
delete updates.customer_name;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(updates, 'customer_email')) {
|
||||
const nextEmail = getCustomerEmailFromPayload(updates);
|
||||
if (nextEmail) {
|
||||
if (customerColumnsAvailable) {
|
||||
updates.customer_email = nextEmail;
|
||||
} else {
|
||||
delete updates.customer_email;
|
||||
}
|
||||
updates.host_email = nextEmail;
|
||||
} else {
|
||||
delete updates.customer_email;
|
||||
}
|
||||
}
|
||||
|
||||
const hasRequirePasswordUpdate = Object.prototype.hasOwnProperty.call(updates, 'require_password');
|
||||
let requirePasswordUpdate;
|
||||
if (hasRequirePasswordUpdate) {
|
||||
requirePasswordUpdate = parseBooleanInput(updates.require_password, true);
|
||||
updates.require_password = formatBoolean(requirePasswordUpdate);
|
||||
}
|
||||
|
||||
let newPasswordPlain;
|
||||
if (Object.prototype.hasOwnProperty.call(updates, 'password')) {
|
||||
if (updates.password === undefined || updates.password === null || updates.password === '') {
|
||||
delete updates.password;
|
||||
} else {
|
||||
newPasswordPlain = updates.password;
|
||||
delete updates.password;
|
||||
}
|
||||
}
|
||||
|
||||
const event = await db('events').where('id', id).first();
|
||||
if (!event) {
|
||||
return res.status(404).json({ error: 'Event not found' });
|
||||
}
|
||||
|
||||
const currentRequirePassword = parseBooleanInput(event.require_password, true);
|
||||
|
||||
if (hasRequirePasswordUpdate && requirePasswordUpdate === true && !currentRequirePassword && !newPasswordPlain) {
|
||||
return res.status(400).json({ error: 'Password must be provided when enabling password requirement.' });
|
||||
}
|
||||
|
||||
if (newPasswordPlain) {
|
||||
updates.password_hash = await bcrypt.hash(newPasswordPlain, getBcryptRounds());
|
||||
} else if (hasRequirePasswordUpdate && requirePasswordUpdate === false && currentRequirePassword) {
|
||||
updates.password_hash = await bcrypt.hash(crypto.randomBytes(32).toString('hex'), getBcryptRounds());
|
||||
}
|
||||
|
||||
await db('events').where('id', id).update(updates);
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to update event' });
|
||||
}
|
||||
});
|
||||
|
||||
// Delete event (mark as inactive)
|
||||
router.delete('/:id', adminAuth, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
await db('events').where('id', id).update({ is_active: formatBoolean(false) });
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to delete event' });
|
||||
}
|
||||
});
|
||||
|
||||
// Extend expiration
|
||||
router.post('/:id/extend', adminAuth, [
|
||||
body('days').isInt({ min: 1, max: 365 })
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { days } = req.body;
|
||||
|
||||
const event = await db('events').where('id', id).first();
|
||||
if (!event) {
|
||||
return res.status(404).json({ error: 'Event not found' });
|
||||
}
|
||||
|
||||
const newExpiration = new Date(event.expires_at);
|
||||
newExpiration.setDate(newExpiration.getDate() + days);
|
||||
|
||||
await db('events').where('id', id).update({
|
||||
expires_at: newExpiration,
|
||||
is_active: formatBoolean(true) // Reactivate if expired
|
||||
});
|
||||
|
||||
res.json({ expires_at: newExpiration });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to extend expiration' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -30,6 +30,16 @@ async function initializeUpload(options) {
|
||||
totalChunks
|
||||
} = options;
|
||||
|
||||
// Strip any directory components from the client-supplied filename. It is
|
||||
// later joined onto the temp merge dir (path.join(tempDir, filename)), and
|
||||
// path.join does NOT neutralise `../` — a filename like `../../uploads/
|
||||
// logos/evil.svg` would escape the temp dir and overwrite arbitrary files
|
||||
// (GHSA-pc72-jf53-w28j). basename() collapses it to the leaf name only.
|
||||
const safeFilename = path.basename(String(filename || ''));
|
||||
if (!safeFilename || safeFilename === '.' || safeFilename === '..') {
|
||||
throw new Error('Invalid filename');
|
||||
}
|
||||
|
||||
// Generate unique upload ID
|
||||
const uploadId = crypto.randomUUID();
|
||||
|
||||
@@ -43,7 +53,7 @@ async function initializeUpload(options) {
|
||||
// Store upload metadata
|
||||
const uploadMeta = {
|
||||
uploadId,
|
||||
filename,
|
||||
filename: safeFilename,
|
||||
fileSize,
|
||||
mimeType,
|
||||
eventId,
|
||||
@@ -59,7 +69,7 @@ async function initializeUpload(options) {
|
||||
|
||||
logger.info('Initialized chunked upload', {
|
||||
uploadId,
|
||||
filename,
|
||||
filename: safeFilename,
|
||||
fileSize,
|
||||
expectedChunks,
|
||||
eventId
|
||||
|
||||
@@ -18,6 +18,7 @@ const fsp = require('fs').promises;
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const StreamZip = require('node-stream-zip');
|
||||
const { assertZipEntriesWithin } = require('../utils/safePath');
|
||||
const { db } = require('../database/db');
|
||||
const knexConfig = require('../../knexfile');
|
||||
const { getStoragePath } = require('../config/storage');
|
||||
@@ -80,25 +81,85 @@ function parseNdjson(filePath) {
|
||||
}
|
||||
|
||||
// Re-insert the operator's account inside the restore transaction so they keep
|
||||
// working credentials. If the backup already loaded an admin with the same
|
||||
// email, overwrite that row's credentials with the current account's (current
|
||||
// creds win); otherwise insert the snapshot with a fresh id.
|
||||
// working credentials after the wipe.
|
||||
//
|
||||
// The operator's login + credentials + MFA must be restored, not just the
|
||||
// password. A crafted backup can carry a row with the operator's email whose
|
||||
// two_factor_* fields are attacker-chosen — leaving those in place would let
|
||||
// the backup strip or hijack the operator's MFA, or (cross-instance) pin a TOTP
|
||||
// secret encrypted with the source instance's key the operator can never
|
||||
// satisfy. These columns are scalar/text (recovery codes are a JSON string in a
|
||||
// TEXT column), so writing them needs no special json handling. Relationship/
|
||||
// audit FKs (role_id, created_by) are deliberately NOT forced from the snapshot
|
||||
// — see the update branch below.
|
||||
//
|
||||
// admin_users has UNIQUE constraints on BOTH email and username, and a restored
|
||||
// backup can collide with the operator on either — possibly on two DIFFERENT
|
||||
// rows (one shares the email, another shares the default `admin` username). We
|
||||
// reconcile WITHOUT deleting any restored row: deleting would fire ON DELETE
|
||||
// actions (SQLite) or dangle references such as events.created_by (Postgres,
|
||||
// where replica mode suppresses cascades). Instead:
|
||||
// - if a row already has the operator's email, overwrite it in place (its id
|
||||
// is preserved, so every FK pointing at the operator stays valid);
|
||||
// - if a DIFFERENT row holds the operator's username, rename that row (id
|
||||
// preserved, its own FKs stay valid) to free the username;
|
||||
// - only when no row has the operator's email do we insert a fresh row.
|
||||
async function reinjectCurrentAdmin(trx, currentAdmin) {
|
||||
if (!currentAdmin) return;
|
||||
const existing = await trx('admin_users').whereRaw('lower(email) = lower(?)', [currentAdmin.email]).first();
|
||||
if (existing) {
|
||||
await trx('admin_users').where({ id: existing.id }).update({
|
||||
password_hash: currentAdmin.password_hash,
|
||||
is_active: currentAdmin.is_active,
|
||||
must_change_password: currentAdmin.must_change_password,
|
||||
});
|
||||
|
||||
const emailMatch = await trx('admin_users')
|
||||
.whereRaw('lower(email) = lower(?)', [currentAdmin.email])
|
||||
.first();
|
||||
|
||||
// Free the operator's username if a different row holds it (rename, not delete).
|
||||
const usernameHolder = await trx('admin_users')
|
||||
.whereRaw('lower(username) = lower(?)', [currentAdmin.username])
|
||||
.first();
|
||||
if (usernameHolder && (!emailMatch || usernameHolder.id !== emailMatch.id)) {
|
||||
await trx('admin_users')
|
||||
.where({ id: usernameHolder.id })
|
||||
.update({ username: `${usernameHolder.username}__restored_${usernameHolder.id}` });
|
||||
}
|
||||
|
||||
if (emailMatch) {
|
||||
// Update in place — keeps emailMatch.id so restored FKs to the operator
|
||||
// hold. Write only the AUTH-critical columns (login identity + credentials
|
||||
// + MFA), never the relationship/audit FKs (role_id → roles, created_by →
|
||||
// admin_users). Forcing the operator's pre-restore role_id/created_by here
|
||||
// could reference rows absent from a cross-instance backup and dangle the
|
||||
// FK (SQLite rolls back at commit); the row already carries the backup's
|
||||
// own valid values for those. This still closes the MFA-hijack gap — a
|
||||
// crafted backup can't strip or replace the operator's second factor.
|
||||
const authUpdate = {};
|
||||
for (const field of PRESERVED_AUTH_FIELDS) {
|
||||
if (field in currentAdmin) authUpdate[field] = currentAdmin[field];
|
||||
}
|
||||
await trx('admin_users').where({ id: emailMatch.id }).update(authUpdate);
|
||||
} else {
|
||||
const row = { ...currentAdmin };
|
||||
delete row.id; // let the engine assign a fresh id to avoid collision
|
||||
await trx('admin_users').insert(row);
|
||||
// The operator's email isn't in the backup, so nothing restored references
|
||||
// their id — a fresh row can't dangle a reference TO the operator. Null the
|
||||
// self-referential created_by (its target admin may be absent from this
|
||||
// backup; ON DELETE SET NULL makes null the correct "unknown inviter"
|
||||
// value) so the insert itself can't dangle. Use an explicit max(id)+1
|
||||
// rather than the identity sequence, which batchInsert left unadvanced on
|
||||
// Postgres (a sequence-based insert could collide with a restored id).
|
||||
const snapshot = { ...currentAdmin };
|
||||
delete snapshot.id;
|
||||
if ('created_by' in snapshot) snapshot.created_by = null;
|
||||
const maxRow = await trx('admin_users').max({ m: 'id' }).first();
|
||||
snapshot.id = (Number(maxRow && maxRow.m) || 0) + 1;
|
||||
await trx('admin_users').insert(snapshot);
|
||||
}
|
||||
}
|
||||
|
||||
// AUTH-critical admin_users columns preserved when overwriting a restored row
|
||||
// that shares the operator's email. Deliberately excludes relationship/audit
|
||||
// FKs (role_id, created_by) — see reinjectCurrentAdmin for why.
|
||||
const PRESERVED_AUTH_FIELDS = [
|
||||
'username', 'email', 'password_hash', 'is_active', 'must_change_password',
|
||||
'two_factor_enabled', 'two_factor_secret', 'two_factor_recovery_codes', 'two_factor_enrolled_at',
|
||||
];
|
||||
|
||||
// The json/jsonb columns of a table (Postgres only). The pg driver returns
|
||||
// jsonb as parsed JS values, so on re-insert they must be serialised back to
|
||||
// valid JSON text — otherwise a scalar like the string "PicPeak" is sent
|
||||
@@ -232,6 +293,10 @@ async function importFromPicpeak({ picpeakPath, currentAdminId }) {
|
||||
try {
|
||||
const zip = new StreamZip.async({ file: picpeakPath });
|
||||
try {
|
||||
// Reject ZIP-slip entries before extracting — a crafted .picpeak could
|
||||
// otherwise write outside the staging dir via `../` entry names
|
||||
// (same class as GHSA-jfhw-fj23-fx6x).
|
||||
assertZipEntriesWithin(Object.values(await zip.entries()), staging);
|
||||
await zip.extract(null, staging);
|
||||
} finally {
|
||||
await zip.close();
|
||||
@@ -268,4 +333,5 @@ module.exports = {
|
||||
importFromPicpeak,
|
||||
readManifestFromZip,
|
||||
validateManifest,
|
||||
reinjectCurrentAdmin,
|
||||
};
|
||||
|
||||
@@ -118,7 +118,41 @@ function assertContractPdfPath(filePath) {
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* ZIP-slip guard. `node-stream-zip`'s `extract(null, root)` writes each entry
|
||||
* to `path.join(root, entry.name)` without neutralising `../` — a crafted
|
||||
* archive with an entry named `../../uploads/logos/evil.svg` escapes `root`
|
||||
* and overwrites arbitrary files (GHSA-jfhw-fj23-fx6x). Call this with the
|
||||
* entry list BEFORE extract() to reject any entry that resolves outside the
|
||||
* target directory.
|
||||
*
|
||||
* Purely lexical (path.resolve, no realpath) because the extraction target
|
||||
* does not exist on disk yet. Absolute entry names (`/etc/passwd`) resolve
|
||||
* away from `root` and are caught too. Throws AppError 400 on the first
|
||||
* offending entry so the whole archive is refused.
|
||||
*
|
||||
* @param {Array<{name?: string}>} entries node-stream-zip entry objects
|
||||
* @param {string} extractRoot directory extract() will write into
|
||||
*/
|
||||
function assertZipEntriesWithin(entries, extractRoot) {
|
||||
const rootResolved = path.resolve(extractRoot);
|
||||
const prefix = rootResolved.endsWith(path.sep) ? rootResolved : rootResolved + path.sep;
|
||||
for (const entry of entries || []) {
|
||||
const name = entry && entry.name;
|
||||
if (!name) continue;
|
||||
const target = path.resolve(rootResolved, name);
|
||||
if (target !== rootResolved && !target.startsWith(prefix)) {
|
||||
throw new AppError(
|
||||
`Archive contains an entry that escapes the extraction directory: ${name}`,
|
||||
400,
|
||||
'ZIP_SLIP'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
assertPathInside,
|
||||
assertContractPdfPath,
|
||||
assertZipEntriesWithin,
|
||||
};
|
||||
|
||||
+17
-7
@@ -29,14 +29,24 @@ COPY . .
|
||||
# Build the application
|
||||
RUN npm run build
|
||||
|
||||
# Production stage (Alpine 3.23 with OpenSSL 3.5.5, patched libexpat)
|
||||
FROM nginx:1.28-alpine
|
||||
# Production stage (nginx stable 1.30 on Alpine 3.24). The 1.28 base is a
|
||||
# dead end for the nginx HTTP/2 + rewrite/charset CVEs (CVE-2026-42055 /
|
||||
# -49975 / -9256 / -48142): nginx.org's nginx-module-* packages pin the exact
|
||||
# nginx version, so `apk upgrade` can never pull Alpine's patched 1.28.3-r4 —
|
||||
# nginx fixes have to come via the base image tag, not apk.
|
||||
FROM nginx:1.30-alpine
|
||||
|
||||
# Upgrade all Alpine packages for security fixes. The explicit nginx upgrade
|
||||
# closes the HTTP/2 + rewrite/charset CVEs (CVE-2026-42055 / -49975 / -9256 /
|
||||
# -48142, fixed in nginx 1.28.3-r4) and busts any cached layer still carrying
|
||||
# the vulnerable r1 build.
|
||||
RUN apk upgrade --no-cache && apk add --no-cache --upgrade nginx
|
||||
# Redeclare CACHEBUST — ARGs don't cross stage boundaries, so the builder
|
||||
# stage's declaration never reached this stage. Consuming it in the RUN below
|
||||
# busts that layer's cache every CI run (CACHEBUST=github.run_number), so the
|
||||
# image always picks up current Alpine security updates. Without this, the
|
||||
# upgrade layer was cached indefinitely and builds kept shipping curl 8.19.0 /
|
||||
# c-ares 1.34.6 for weeks after fixed packages landed in the Alpine repo.
|
||||
ARG CACHEBUST=1
|
||||
|
||||
# Upgrade all Alpine packages for security fixes (nginx itself is version-
|
||||
# pinned by its module packages — see the FROM comment above).
|
||||
RUN echo "cachebust=${CACHEBUST}" && apk upgrade --no-cache
|
||||
|
||||
# Install runtime dependencies. `gettext` provides envsubst, used by
|
||||
# docker-entrypoint.sh for the BRAND_TITLE / BRAND_DESCRIPTION runtime
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "picpeak-frontend",
|
||||
"private": true,
|
||||
"version": "3.83.0-beta.0",
|
||||
"version": "3.45.2",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -16,6 +16,7 @@ interface RestoreResult {
|
||||
tables: number;
|
||||
filesRestored: number;
|
||||
usesExternalMedia: boolean;
|
||||
sessionInvalidated?: boolean;
|
||||
}
|
||||
|
||||
// ── Download half (Dashboard) ────────────────────────────────────────────────
|
||||
@@ -114,6 +115,13 @@ export const PicpeakRestoreCard: React.FC = () => {
|
||||
setResult(res.data);
|
||||
setPendingFile(null);
|
||||
toast.success(t('backup.picpeak.restoreDone', 'Backup restored.'));
|
||||
// The restore rewrote admin_users and the backend revoked our session
|
||||
// (ids may have shifted). Send the operator to a fresh login rather than
|
||||
// letting the now-stale token resolve to a different restored account.
|
||||
if (res.data?.sessionInvalidated) {
|
||||
toast.success(t('backup.picpeak.reloginRequired', 'Restore complete — please sign in again.'));
|
||||
setTimeout(() => { window.location.href = '/admin/login'; }, 1500);
|
||||
}
|
||||
} catch (e: any) {
|
||||
const msg = e.response?.data?.error || t('backup.picpeak.restoreFailed', 'Restore failed.');
|
||||
toast.error(msg);
|
||||
|
||||
@@ -201,9 +201,10 @@ export const eventsService = {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Extend event expiration (admin)
|
||||
// Extend event expiration (admin). Uses the canonical, ownership-guarded
|
||||
// route; the old /events/:id/extend legacy endpoint was removed (GHSA-4j34).
|
||||
async extendExpiration(id: number, days: number): Promise<Event> {
|
||||
const response = await api.post<Event>(`/events/${id}/extend`, {
|
||||
const response = await api.post<Event>(`/admin/events/${id}/extend`, {
|
||||
days,
|
||||
});
|
||||
return response.data;
|
||||
|
||||
Reference in New Issue
Block a user