Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4bb0210a2b | |||
| 70bd03dc1d | |||
| e18a0060a0 | |||
| f36c36166b | |||
| 6d906349bf | |||
| 143c4035ec |
@@ -1 +1 @@
|
||||
{".":"3.46.10"}
|
||||
{".":"3.46.11"}
|
||||
|
||||
@@ -5,6 +5,13 @@ All notable changes to PicPeak will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [3.46.11](https://github.com/PicPeak/picpeak/compare/v3.46.10...v3.46.11) (2026-09-08)
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* align stable security and backport policy ([#1352](https://github.com/PicPeak/picpeak/issues/1352)) ([143c403](https://github.com/PicPeak/picpeak/commit/143c4035ec38683634d0e3d493032e2965f4a46f))
|
||||
|
||||
## [3.46.10](https://github.com/PicPeak/picpeak/compare/v3.46.9...v3.46.10) (2026-09-07)
|
||||
|
||||
|
||||
|
||||
+5
-4
@@ -163,13 +163,14 @@ PicPeak runs on two long-lived branches:
|
||||
| Branch | Role | What targets it |
|
||||
|---|---|---|
|
||||
| **`main`** | Active development. The next release is being assembled here. | Feature PRs. Most bugfix PRs. |
|
||||
| **`stable`** | Curated release channel. Production-recommended. | Urgent bugfix backports only — small, surgical PRs that land cleanly without dragging in unrelated changes. |
|
||||
| **`stable`** | Curated release channel. Production-recommended. | Security fixes and regular bugfix backports, kept small and free of unrelated features. |
|
||||
|
||||
### Which branch should my PR target?
|
||||
|
||||
- **New feature** → target `main`.
|
||||
- **Bugfix that ONLY affects active dev** → target `main`.
|
||||
- **Bugfix that current stable users need** → open a small PR against `main`, AND a separate small PR against `stable` with the same change. Keep both surgical so each lands cleanly.
|
||||
- **Bugfix that current stable users need** → target `main`; regular bug fixes are generally backported automatically to `stable`. Maintainers handle conflicts or create a separate focused backport PR when needed.
|
||||
- **Security vulnerability** → report privately using [SECURITY.md](SECURITY.md). Security fixes are always released on both `stable` and `main`; coordinate any fix with the maintainers before opening a public PR.
|
||||
|
||||
**Hard rule on PR scope**: bugfix PRs against `stable` must be small enough to backport without conflict. Omnibus PRs (e.g. five unrelated sub-features) are fine for `main`, but never for `stable` — they make the next `main → stable` merge painful and break the "stable is always shippable" invariant.
|
||||
|
||||
@@ -187,6 +188,6 @@ See [RELEASING.md](RELEASING.md) for the full operational doc (promotion criteri
|
||||
|
||||
- Create an [issue](https://github.com/PicPeak/picpeak/issues) for bugs or features
|
||||
- Join [discussions](https://github.com/PicPeak/picpeak/discussions) for questions
|
||||
- Security issues: Open a [security issue](https://github.com/PicPeak/picpeak/issues/new?labels=security) on GitHub
|
||||
- Security vulnerabilities: Follow the [security policy](SECURITY.md) and use [private vulnerability reporting](https://github.com/PicPeak/picpeak/security/advisories/new)
|
||||
|
||||
Thank you for contributing! 🎉
|
||||
Thank you for contributing! 🎉
|
||||
|
||||
+6
-1
@@ -62,13 +62,18 @@ The actual mechanics, in order:
|
||||
|
||||
## Hotfix path (backport to current stable)
|
||||
|
||||
If a critical bug or security issue affects the current stable and `main` has moved too far for a full promotion to be appropriate, backport just the fix:
|
||||
Regular bug fixes are generally backported automatically from `main` to `stable`. Keep backports focused on the fix, without unrelated features, and resolve conflicts manually when needed.
|
||||
|
||||
**Security fixes are always released on both `stable` and `main`.** Do not wait for a full promotion to deliver a security update. A fix first applied to `stable` must also be forward-ported to `main`; a fix first applied to `main` must also reach `stable`. See [SECURITY.md](SECURITY.md) for the support policy.
|
||||
|
||||
When a backport needs manual handling:
|
||||
|
||||
1. Create a `security/cve-backport-X.Y.Z` or `fix/critical-X.Y.Z` branch off `stable`.
|
||||
2. Cherry-pick or hand-write the minimal fix.
|
||||
3. Open a PR to `stable` with the smallest possible diff.
|
||||
4. After merge, release-please will propose a patch-level stable release (e.g. `v3.55.1`).
|
||||
5. **Forward-port the fix to `main`** if it isn't already there. Otherwise the next full promotion will reintroduce the bug.
|
||||
6. For security fixes, verify that the fix has been published through **both** release channels; merging the code is only part of delivery.
|
||||
|
||||
PR #412 ("backport 18 dependency CVE patches from beta") is a worked example of this path (predates the rename; the mechanics are unchanged).
|
||||
|
||||
|
||||
+61
-68
@@ -1,88 +1,81 @@
|
||||
# Security Policy
|
||||
|
||||
## Scope
|
||||
|
||||
This policy covers the PicPeak backend, frontend, all-in-one (AIO) image, optional
|
||||
ML component, and the Docker images published by the PicPeak project. Other
|
||||
PicPeak repositories define their own supported versions and release channels.
|
||||
|
||||
## Supported Versions
|
||||
|
||||
We release patches for security vulnerabilities. Currently supported versions:
|
||||
Security support follows the current release channels:
|
||||
|
||||
| Version | Supported |
|
||||
| ------- | ------------------ |
|
||||
| 2.x.x | :white_check_mark: |
|
||||
| < 2.0 | :x: |
|
||||
| Version or channel | Security support |
|
||||
| --- | --- |
|
||||
| Latest stable release from `stable` | Supported; security fixes are published through this channel |
|
||||
| Latest beta release from `main` | Supported; security fixes are published through this channel |
|
||||
| Superseded stable or beta releases | Upgrade to the latest release in the same channel; older releases are not maintained separately |
|
||||
| 2.x and earlier | No longer supported |
|
||||
|
||||
See the [latest stable release](https://github.com/PicPeak/picpeak/releases/latest)
|
||||
and [all releases, including betas](https://github.com/PicPeak/picpeak/releases).
|
||||
Version numbers differ between channels; each channel receives its own updates.
|
||||
|
||||
### Security fixes and bug backports
|
||||
|
||||
**Security fixes are always released on both `stable` and `main`.** A fix that
|
||||
lands on one branch must also reach the other branch and be published through
|
||||
both release channels. Security updates do not wait for the next full
|
||||
`main`-to-`stable` promotion.
|
||||
|
||||
Regular bug fixes are also generally backported automatically to `stable`.
|
||||
Backports remain focused on the fix, without pulling in unrelated features.
|
||||
Maintainers resolve conflicts or handle a backport manually when necessary.
|
||||
|
||||
The [release process](RELEASING.md) describes backports, forward-ports and
|
||||
publication. Operators must apply the published updates to their installations.
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
We take the security of PicPeak seriously. If you have discovered a security vulnerability, please follow these steps:
|
||||
**Do not report vulnerabilities in public issues, discussions or pull requests.**
|
||||
|
||||
### 1. **Do NOT create a public GitHub issue**
|
||||
Report privately through:
|
||||
|
||||
### 2. Report the vulnerability privately by:
|
||||
- **Preferred:** Use [GitHub Private Vulnerability Reporting](https://github.com/PicPeak/picpeak/security/advisories/new)
|
||||
- **Alternative:** Email us at **info@picpeak.app** with the details
|
||||
- Include:
|
||||
- Description of the vulnerability
|
||||
- Steps to reproduce
|
||||
- Potential impact
|
||||
- Suggested fix (if any)
|
||||
- [GitHub Private Vulnerability Reporting](https://github.com/PicPeak/picpeak/security/advisories/new) (preferred).
|
||||
- Email **info@picpeak.app** if you cannot use GitHub's private reporting form.
|
||||
|
||||
### 3. You can expect:
|
||||
- Acknowledgment within 48 hours
|
||||
- Regular updates on our progress
|
||||
- Credit in the fix announcement (unless you prefer to remain anonymous)
|
||||
Include the affected component, version or image tag, deployment method,
|
||||
reproduction steps, expected impact and any suggested fix. Share only the
|
||||
information needed to reproduce the problem; remove credentials and personal
|
||||
data from logs or examples.
|
||||
|
||||
## Security Measures
|
||||
We aim to acknowledge reports within 48 hours. This is a response target, not a
|
||||
guaranteed service level or a promised resolution time. We will provide progress
|
||||
updates and coordinate disclosure with the reporter. Reporter credit is optional;
|
||||
tell us if you prefer to remain anonymous.
|
||||
|
||||
PicPeak implements several security measures:
|
||||
## Deployment Security
|
||||
|
||||
### Authentication & Authorization
|
||||
- JWT-based authentication with secure token storage
|
||||
- bcrypt password hashing with configurable rounds
|
||||
- Role-based access control for admin functions
|
||||
- Session timeout management
|
||||
Security depends on both the software and its configuration. Operators should:
|
||||
|
||||
### Input Validation
|
||||
- All user inputs are validated and sanitized
|
||||
- SQL injection prevention through parameterized queries
|
||||
- XSS protection via Content Security Policy
|
||||
- File upload restrictions and validation
|
||||
- Use HTTPS and configure the reverse proxy and trusted proxy settings correctly.
|
||||
- Use strong credentials and keep deployment secrets private.
|
||||
- Apply updates for the chosen release channel and restrict unnecessary network access.
|
||||
- Keep backups and verify that they can be restored.
|
||||
|
||||
### Rate Limiting
|
||||
- API rate limiting to prevent abuse
|
||||
- Brute force protection on authentication endpoints
|
||||
- Configurable limits per endpoint
|
||||
|
||||
### Data Protection
|
||||
- HTTPS enforcement in production
|
||||
- Secure cookie settings
|
||||
- CORS configuration
|
||||
- Sensitive data encryption
|
||||
|
||||
### Infrastructure
|
||||
- Regular dependency updates
|
||||
- Security headers (HSTS, X-Frame-Options, etc.)
|
||||
- Activity logging for audit trails
|
||||
- Automated backups
|
||||
|
||||
## Best Practices for Deployment
|
||||
|
||||
1. **Always use HTTPS** in production
|
||||
2. **Change default passwords** immediately
|
||||
3. **Keep dependencies updated** regularly
|
||||
4. **Configure firewall rules** appropriately
|
||||
5. **Monitor logs** for suspicious activity
|
||||
6. **Backup regularly** and test restoration
|
||||
See the deployment guides for [HTTPS](https://docs.picpeak.app/deployment/ssl-certificates),
|
||||
[reverse proxies](https://docs.picpeak.app/deployment/reverse-proxy),
|
||||
[security settings](https://docs.picpeak.app/guides/admin-settings/security)
|
||||
and [backup and restore](https://docs.picpeak.app/guides/backup-restore).
|
||||
|
||||
## Vulnerability Disclosure
|
||||
|
||||
We believe in responsible disclosure. Once a vulnerability is fixed:
|
||||
We coordinate disclosure with the reporter while preparing fixes. Security fixes
|
||||
are published through both supported channels. Advisories and release notes
|
||||
identify affected versions, the fixed version in each channel, the impact and
|
||||
any required mitigation or upgrade steps. Reporter credit is included with
|
||||
permission.
|
||||
|
||||
1. We'll publish a security advisory
|
||||
2. Credit researchers (with permission)
|
||||
3. Detail the impact and mitigation steps
|
||||
4. Release patches for all supported versions
|
||||
|
||||
## Contact
|
||||
|
||||
- Security issues: Email **info@picpeak.app** or use [GitHub Private Vulnerability Reporting](https://github.com/PicPeak/picpeak/security/advisories/new)
|
||||
- General support: [GitHub Issues](https://github.com/PicPeak/picpeak/issues)
|
||||
|
||||
Thank you for helping keep PicPeak and its users safe!
|
||||
For ordinary bugs and support requests, use
|
||||
[GitHub Issues](https://github.com/PicPeak/picpeak/issues) or
|
||||
[GitHub Discussions](https://github.com/PicPeak/picpeak/discussions).
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* PUT /api/admin/database-backup/config must reject a
|
||||
* database_backup_destination_path that resolves inside a publicly served
|
||||
* directory (GHSA-jw8m-43r2-jqrm class, #1365).
|
||||
*
|
||||
* Before #1365, database_backup_destination_path was silently ignored by
|
||||
* databaseBackupService.backup() (a destructuring bug always fell back to
|
||||
* the hardcoded /backup/database), so this setting being freely writable by
|
||||
* any backup.create holder — the built-in `admin` role has it without
|
||||
* settings.edit or backup.restore — was harmless. Making the setting
|
||||
* actually take effect reopens the exact exfiltration path GHSA-jw8m fixed
|
||||
* for the per-request override, through the persisted setting instead.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-dbbackup-config-')), 'db.sqlite',
|
||||
);
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'dbbackup-config-test-secret';
|
||||
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-dbbackup-storage-'));
|
||||
|
||||
const request = require('supertest');
|
||||
const express = require('express');
|
||||
const bcrypt = require('bcrypt');
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
|
||||
|
||||
describe('database backup destination-path config guard (GHSA-jw8m class, #1365)', () => {
|
||||
let db; let cleanup; let app; let adminToken;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
await seedMinimal(db);
|
||||
|
||||
const role = await db('roles').where({ name: 'admin' }).first();
|
||||
const r = await db('admin_users').insert({
|
||||
username: 'limited-admin',
|
||||
email: 'limited-admin-config@example.com',
|
||||
password_hash: await bcrypt.hash('Passw0rd!', 4),
|
||||
role_id: role.id,
|
||||
is_active: 1,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
}).returning('id');
|
||||
const id = r[0]?.id ?? r[0];
|
||||
adminToken = jwt.sign(
|
||||
{ id, username: 'limited-admin', type: 'admin', role: 'admin', loginTime: Date.now() },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: '1h', issuer: 'picpeak-auth' },
|
||||
);
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/admin/database-backup', require('../../src/routes/adminDatabaseBackup'));
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
it('rejects a destination inside the public uploads/logos mount', async () => {
|
||||
const res = await request(app)
|
||||
.put('/api/admin/database-backup/config')
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.send({ database_backup_destination_path: path.join(process.env.STORAGE_PATH, 'uploads', 'logos') });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
|
||||
// The seeded default must survive untouched — the rejected value never lands.
|
||||
const row = await db('app_settings').where({ setting_key: 'database_backup_destination_path' }).first();
|
||||
expect(JSON.parse(row.setting_value)).toBe('/backup/database');
|
||||
});
|
||||
|
||||
it('rejects a destination inside the public fonts mount', async () => {
|
||||
const res = await request(app)
|
||||
.put('/api/admin/database-backup/config')
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.send({ database_backup_destination_path: path.join(process.env.STORAGE_PATH, 'fonts') });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('accepts a destination outside any public mount', async () => {
|
||||
const safePath = path.join(process.env.STORAGE_PATH, 'db-backups');
|
||||
const res = await request(app)
|
||||
.put('/api/admin/database-backup/config')
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.send({ database_backup_destination_path: safePath });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const row = await db('app_settings').where({ setting_key: 'database_backup_destination_path' }).first();
|
||||
expect(JSON.parse(row.setting_value)).toBe(safePath);
|
||||
});
|
||||
|
||||
// A retention of 0 or less pushes cleanupOldBackups' cutoff to today or
|
||||
// the future, deleting every completed backup on the next scheduled run
|
||||
// — a backup.create holder achieving what backup.delete gates on /cleanup.
|
||||
it.each([-1, 0])('rejects database_backup_retention_days=%s', async (bad) => {
|
||||
const res = await request(app)
|
||||
.put('/api/admin/database-backup/config')
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.send({ database_backup_retention_days: bad });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('accepts a positive database_backup_retention_days', async () => {
|
||||
const res = await request(app)
|
||||
.put('/api/admin/database-backup/config')
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.send({ database_backup_retention_days: 90 });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const row = await db('app_settings').where({ setting_key: 'database_backup_retention_days' }).first();
|
||||
expect(JSON.parse(row.setting_value)).toBe(90);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "picpeak-backend",
|
||||
"version": "3.46.10",
|
||||
"version": "3.46.11",
|
||||
"description": "Backend for PicPeak event photo sharing platform",
|
||||
"main": "server.js",
|
||||
"engines": {
|
||||
|
||||
@@ -2,7 +2,7 @@ const express = require('express');
|
||||
const router = express.Router();
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const { databaseBackupService } = require('../services/databaseBackup');
|
||||
const { databaseBackupService, isUnderPubliclyServableRoot } = require('../services/databaseBackup');
|
||||
const { db } = require('../database/db');
|
||||
const logger = require('../utils/logger');
|
||||
const { getPagination } = require('../utils/routeHelpers');
|
||||
@@ -60,7 +60,28 @@ router.put('/config', requirePermission('backup.create'), async (req, res) => {
|
||||
'database_backup_email_on_failure',
|
||||
'database_backup_email_on_success'
|
||||
];
|
||||
|
||||
|
||||
// A backup.create holder (the built-in `admin` role has it without
|
||||
// settings.edit or backup.restore) could otherwise point backups at a
|
||||
// public static mount and fetch the dump unauthenticated — see
|
||||
// isUnderPubliclyServableRoot's comment (GHSA-jw8m-43r2-jqrm class).
|
||||
if (
|
||||
typeof req.body.database_backup_destination_path === 'string'
|
||||
&& isUnderPubliclyServableRoot(req.body.database_backup_destination_path)
|
||||
) {
|
||||
return res.status(400).json({ error: 'Destination path must not be inside a publicly served directory' });
|
||||
}
|
||||
|
||||
// A retention of 0 or less pushes cleanupOldBackups' cutoff to today or
|
||||
// the future, deleting every completed backup on the next scheduled run
|
||||
// — a backup.create holder achieving what backup.delete gates on /cleanup.
|
||||
if (
|
||||
req.body.database_backup_retention_days !== undefined
|
||||
&& (!Number.isFinite(req.body.database_backup_retention_days) || req.body.database_backup_retention_days < 1)
|
||||
) {
|
||||
return res.status(400).json({ error: 'database_backup_retention_days must be a positive number' });
|
||||
}
|
||||
|
||||
const updates = [];
|
||||
|
||||
for (const [key, value] of Object.entries(req.body)) {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
const { DatabaseBackupService } = require('../databaseBackup');
|
||||
const { db } = require('../../database/db');
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
@@ -9,6 +8,10 @@ jest.mock('../../database/db');
|
||||
jest.mock('../../utils/logger');
|
||||
jest.mock('../emailProcessor');
|
||||
jest.mock('child_process');
|
||||
jest.mock('node-cron', () => ({ schedule: jest.fn(() => ({ stop: jest.fn() })) }));
|
||||
|
||||
const { DatabaseBackupService, startScheduledBackups, databaseBackupService, isUnderPubliclyServableRoot } = require('../databaseBackup');
|
||||
const cron = require('node-cron');
|
||||
|
||||
describe('DatabaseBackupService', () => {
|
||||
let service;
|
||||
@@ -188,6 +191,213 @@ describe('DatabaseBackupService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('backup() destination path resolution (#1365)', () => {
|
||||
// getBackupConfig() returns database_backup_*-prefixed keys.
|
||||
// Regression: backup() used to destructure the unprefixed names
|
||||
// (`destinationPath`, ...) straight off that object, which never
|
||||
// matched, so the configured path was silently ignored and every
|
||||
// run tried to create the hardcoded /backup/database default.
|
||||
it('creates the directory from database_backup_destination_path when configured', async () => {
|
||||
db.mockReturnValue({
|
||||
where: jest.fn().mockReturnThis(),
|
||||
select: jest.fn().mockResolvedValue([
|
||||
{ setting_key: 'database_backup_destination_path', setting_value: JSON.stringify('/data/db-backups') }
|
||||
])
|
||||
});
|
||||
|
||||
const stop = new Error('stop after mkdir — nothing past it matters for this test');
|
||||
const mkdirSpy = jest.spyOn(fs, 'mkdir').mockRejectedValue(stop);
|
||||
|
||||
await expect(service.backup({})).rejects.toThrow(stop.message);
|
||||
|
||||
expect(mkdirSpy).toHaveBeenCalledWith('/data/db-backups', { recursive: true });
|
||||
mkdirSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('falls back to /backup/database only when nothing is configured', async () => {
|
||||
db.mockReturnValue({
|
||||
where: jest.fn().mockReturnThis(),
|
||||
select: jest.fn().mockResolvedValue([])
|
||||
});
|
||||
|
||||
const stop = new Error('stop after mkdir');
|
||||
const mkdirSpy = jest.spyOn(fs, 'mkdir').mockRejectedValue(stop);
|
||||
|
||||
await expect(service.backup({})).rejects.toThrow(stop.message);
|
||||
|
||||
expect(mkdirSpy).toHaveBeenCalledWith('/backup/database', { recursive: true });
|
||||
mkdirSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isUnderPubliclyServableRoot (GHSA-jw8m class, #1365)', () => {
|
||||
const originalStoragePath = process.env.STORAGE_PATH;
|
||||
const storage = '/tmp/picpeak-test-storage';
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.STORAGE_PATH = storage;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
if (originalStoragePath === undefined) {
|
||||
delete process.env.STORAGE_PATH;
|
||||
} else {
|
||||
process.env.STORAGE_PATH = originalStoragePath;
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
path.join(storage, 'uploads', 'logos'),
|
||||
path.join(storage, 'uploads', 'logos', 'sub'),
|
||||
path.join(storage, 'uploads', 'favicons'),
|
||||
path.join(storage, 'fonts'),
|
||||
path.join(storage, 'fonts', 'inter'),
|
||||
// Bundled fallback fonts — nodejs-owned per the Dockerfile's
|
||||
// COPY --chown, and served at the same public /fonts route.
|
||||
path.resolve(__dirname, '../../../assets/fonts'),
|
||||
// Case-insensitive-but-preserving filesystems (APFS, NTFS, Docker
|
||||
// Desktop bind mounts of either) resolve this to the same directory
|
||||
// as uploads/logos even though path.resolve() never folds case.
|
||||
path.join(storage, 'UPLOADS', 'Logos')
|
||||
])('flags %s as publicly servable', (candidate) => {
|
||||
expect(isUnderPubliclyServableRoot(candidate)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
path.join(storage, 'backups'),
|
||||
path.join(storage, 'uploads', 'contracts', 'signed'),
|
||||
path.join(storage, 'uploads', 'transfers', '123'),
|
||||
'/data/db-backups'
|
||||
])('does not flag %s', (candidate) => {
|
||||
expect(isUnderPubliclyServableRoot(candidate)).toBe(false);
|
||||
});
|
||||
|
||||
it('backup() refuses a destination inside a publicly servable root without ever calling mkdir', async () => {
|
||||
const publicPath = path.join(storage, 'uploads', 'logos');
|
||||
db.mockReturnValue({
|
||||
where: jest.fn().mockReturnThis(),
|
||||
select: jest.fn().mockResolvedValue([
|
||||
{ setting_key: 'database_backup_destination_path', setting_value: JSON.stringify(publicPath) }
|
||||
])
|
||||
});
|
||||
|
||||
const mkdirSpy = jest.spyOn(fs, 'mkdir');
|
||||
|
||||
await expect(service.backup({})).rejects.toThrow('publicly served directory');
|
||||
|
||||
expect(mkdirSpy).not.toHaveBeenCalled();
|
||||
mkdirSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('flags FRONTEND_DIR — the all-in-one image serves its built SPA unauthenticated', () => {
|
||||
const originalFrontendDir = process.env.FRONTEND_DIR;
|
||||
process.env.FRONTEND_DIR = '/app/frontend/dist';
|
||||
try {
|
||||
expect(isUnderPubliclyServableRoot('/app/frontend/dist')).toBe(true);
|
||||
expect(isUnderPubliclyServableRoot(path.join('/app/frontend/dist', 'assets'))).toBe(true);
|
||||
} finally {
|
||||
if (originalFrontendDir === undefined) delete process.env.FRONTEND_DIR;
|
||||
else process.env.FRONTEND_DIR = originalFrontendDir;
|
||||
}
|
||||
});
|
||||
|
||||
it('resolves a symlinked alias of a public root to the same real directory (all-in-one /app/storage -> /data/storage)', async () => {
|
||||
const os = require('os');
|
||||
const realRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'picpeak-real-'));
|
||||
const linkRoot = path.join(os.tmpdir(), `picpeak-link-${process.pid}-${Date.now()}`);
|
||||
await fs.mkdir(path.join(realRoot, 'uploads', 'logos'), { recursive: true });
|
||||
await fs.symlink(realRoot, linkRoot, 'dir');
|
||||
|
||||
try {
|
||||
// STORAGE_PATH (what the guard's roots are built from) is the real
|
||||
// path; the attacker-supplied destination goes through the symlink
|
||||
// — exactly the all-in-one image's /app/storage -> /data/storage.
|
||||
process.env.STORAGE_PATH = realRoot;
|
||||
const aliased = path.join(linkRoot, 'uploads', 'logos');
|
||||
|
||||
expect(isUnderPubliclyServableRoot(aliased)).toBe(true);
|
||||
} finally {
|
||||
await fs.unlink(linkRoot);
|
||||
await fs.rm(realRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('startScheduledBackups (#1365)', () => {
|
||||
// Same key-mismatch bug as backup(): getBackupConfig() returns
|
||||
// database_backup_*-prefixed keys, but this read `config.enabled` /
|
||||
// `config.schedule` / `config.retentionDays` — always undefined, so
|
||||
// the scheduler silently treated every install as disabled.
|
||||
it('does not start the schedule while database_backup_enabled is false', async () => {
|
||||
db.mockReturnValue({
|
||||
where: jest.fn().mockReturnThis(),
|
||||
select: jest.fn().mockResolvedValue([
|
||||
{ setting_key: 'database_backup_enabled', setting_value: 'false' }
|
||||
])
|
||||
});
|
||||
|
||||
await startScheduledBackups();
|
||||
|
||||
expect(cron.schedule).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('starts the schedule with the configured cron when database_backup_enabled is true', async () => {
|
||||
db.mockReturnValue({
|
||||
where: jest.fn().mockReturnThis(),
|
||||
select: jest.fn().mockResolvedValue([
|
||||
{ setting_key: 'database_backup_enabled', setting_value: 'true' },
|
||||
{ setting_key: 'database_backup_schedule', setting_value: JSON.stringify('0 4 * * *') }
|
||||
])
|
||||
});
|
||||
|
||||
await startScheduledBackups();
|
||||
|
||||
expect(cron.schedule).toHaveBeenCalledWith('0 4 * * *', expect.any(Function));
|
||||
});
|
||||
|
||||
it('re-reads retention on every tick instead of the value captured at schedule start (#1365)', async () => {
|
||||
db.mockReturnValue({
|
||||
where: jest.fn().mockReturnThis(),
|
||||
select: jest.fn().mockResolvedValue([
|
||||
{ setting_key: 'database_backup_enabled', setting_value: 'true' },
|
||||
{ setting_key: 'database_backup_retention_days', setting_value: JSON.stringify(30) }
|
||||
])
|
||||
});
|
||||
|
||||
await startScheduledBackups();
|
||||
const tick = cron.schedule.mock.calls[0][1];
|
||||
|
||||
// A /config update between schedule-start and this tick raised
|
||||
// retention to 365 — the closed-over 30 must not be what runs.
|
||||
db.mockReturnValue({
|
||||
where: jest.fn().mockReturnThis(),
|
||||
select: jest.fn().mockResolvedValue([
|
||||
{ setting_key: 'database_backup_enabled', setting_value: 'true' },
|
||||
{ setting_key: 'database_backup_retention_days', setting_value: JSON.stringify(365) }
|
||||
])
|
||||
});
|
||||
jest.spyOn(databaseBackupService, 'backup').mockResolvedValue({ success: true });
|
||||
const cleanupSpy = jest.spyOn(databaseBackupService, 'cleanupOldBackups').mockResolvedValue(undefined);
|
||||
|
||||
await tick();
|
||||
|
||||
expect(cleanupSpy).toHaveBeenCalledWith(365);
|
||||
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleanupOldBackups destructive-retention guard (#1365)', () => {
|
||||
it.each([-1, 0, NaN, Infinity])('refuses retentionDays=%s without touching the database', async (bad) => {
|
||||
const dbSpy = jest.fn();
|
||||
db.mockImplementation(dbSpy);
|
||||
|
||||
await service.cleanupOldBackups(bad);
|
||||
|
||||
expect(dbSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleanupOldBackups', () => {
|
||||
it('should delete old backup files and records', async () => {
|
||||
const oldBackups = [
|
||||
|
||||
@@ -1043,7 +1043,7 @@ function buildManifestFiles(backedUpFiles, allFiles) {
|
||||
|
||||
async function saveManifestToLocal(manifest, manifestFileName, config) {
|
||||
const manifestDir = config.backup_manifest_path
|
||||
|| path.join(config.backup_destination_path || '/backup', 'manifests');
|
||||
|| path.join(config.backup_destination_path || path.join(getStoragePath(), 'backups'), 'manifests');
|
||||
await fs.mkdir(manifestDir, { recursive: true });
|
||||
const manifestPath = path.join(manifestDir, manifestFileName);
|
||||
await backupManifest.saveManifest(manifest, manifestPath, config.backup_manifest_format || 'json');
|
||||
|
||||
@@ -4,7 +4,7 @@ const crypto = require('crypto');
|
||||
const { spawnAsync, spawnToFile } = require('../utils/safeExec');
|
||||
const zlib = require('zlib');
|
||||
const { pipeline } = require('stream/promises');
|
||||
const { createReadStream, createWriteStream } = require('fs');
|
||||
const { createReadStream, createWriteStream, realpathSync } = require('fs');
|
||||
const { db } = require('../database/db');
|
||||
const knexConfig = require('../../knexfile');
|
||||
const logger = require('../utils/logger');
|
||||
@@ -16,6 +16,76 @@ const packageJson = require('../../package.json');
|
||||
const CHUNK_SIZE = 1024 * 1024; // 1MB chunks for streaming
|
||||
const PROGRESS_INTERVAL = 100; // Report progress every 100 rows
|
||||
|
||||
function getStoragePath() {
|
||||
return process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
}
|
||||
|
||||
// Public, unauthenticated static mounts (server.js) that must never become a
|
||||
// backup destination — a dump landing there is downloadable by anyone who
|
||||
// learns or guesses the filename, GHSA-jw8m-43r2-jqrm's exact class. Before
|
||||
// #1365, `database_backup_destination_path` was silently ignored (a
|
||||
// destructuring bug always fell back to the hardcoded /backup/database), so
|
||||
// this setting being freely writable by any backup.create holder — the
|
||||
// built-in `admin` role has it without settings.edit or backup.restore — was
|
||||
// harmless. Making the setting actually take effect reopens that exact
|
||||
// exfiltration path unless it's rejected here too.
|
||||
function getPubliclyServableRoots() {
|
||||
const storage = getStoragePath();
|
||||
return [
|
||||
path.join(storage, 'uploads', 'logos'),
|
||||
path.join(storage, 'uploads', 'favicons'),
|
||||
path.join(storage, 'fonts'),
|
||||
// Bundled fallback fonts (server.js mounts both at /fonts, storage wins
|
||||
// on overlap but express.static falls through to this one on a miss).
|
||||
// COPY --chown=nodejs:nodejs in the Dockerfile makes this nodejs-owned
|
||||
// and therefore writable at runtime, not just a read-only image layer.
|
||||
path.resolve(__dirname, '../../assets/fonts'),
|
||||
// The all-in-one image's built frontend bundle (Dockerfile.aio ships it
|
||||
// nodejs-owned) — server.js serves it unauthenticated as the SPA itself.
|
||||
process.env.FRONTEND_DIR || path.resolve(__dirname, '../../../frontend/dist')
|
||||
];
|
||||
}
|
||||
|
||||
// Resolves symlinks in whatever prefix of candidatePath currently exists,
|
||||
// then re-appends any not-yet-created remainder literally. A plain
|
||||
// fs.realpathSync would throw ENOENT for the common case where the backup
|
||||
// destination doesn't exist yet; a plain path.resolve() would miss the
|
||||
// all-in-one image's `/app/storage -> /data/storage` symlink (Dockerfile.aio),
|
||||
// which lets `/app/storage/uploads/logos` alias the real public logos
|
||||
// directory under a name that never lexically matches it.
|
||||
function resolveRealish(candidatePath) {
|
||||
let current = path.resolve(candidatePath);
|
||||
const remainder = [];
|
||||
for (;;) {
|
||||
try {
|
||||
const real = realpathSync(current);
|
||||
return remainder.length ? path.join(real, ...remainder) : real;
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') {
|
||||
return path.resolve(candidatePath);
|
||||
}
|
||||
const parent = path.dirname(current);
|
||||
if (parent === current) {
|
||||
return path.resolve(candidatePath);
|
||||
}
|
||||
remainder.unshift(path.basename(current));
|
||||
current = parent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isUnderPubliclyServableRoot(candidatePath) {
|
||||
// Lowercased comparison: on a case-insensitive-but-preserving filesystem
|
||||
// (default macOS APFS, NTFS, and Docker Desktop's bind-mount passthrough
|
||||
// of either) `STORAGE_PATH/UPLOADS/logos` and `.../uploads/logos` name the
|
||||
// same directory on disk even though path.resolve() never folds case.
|
||||
const resolved = resolveRealish(candidatePath).toLowerCase();
|
||||
return getPubliclyServableRoots().some((root) => {
|
||||
const resolvedRoot = resolveRealish(root).toLowerCase();
|
||||
return resolved === resolvedRoot || resolved.startsWith(resolvedRoot + path.sep);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Database Backup Service
|
||||
* Supports both SQLite and PostgreSQL with proper escaping,
|
||||
@@ -296,15 +366,33 @@ class DatabaseBackupService {
|
||||
let backupRun = null;
|
||||
|
||||
try {
|
||||
// Get configuration
|
||||
// Get configuration. getBackupConfig() returns the raw
|
||||
// database_backup_*-prefixed setting keys, not the unprefixed
|
||||
// names used internally below — map them explicitly rather than
|
||||
// spreading `config` straight into the destructure, which silently
|
||||
// matched nothing and always fell through to the hardcoded
|
||||
// defaults (notably `/backup/database`, regardless of what was
|
||||
// configured).
|
||||
const config = await this.getBackupConfig();
|
||||
const {
|
||||
destinationPath = '/backup/database',
|
||||
compress = true,
|
||||
validateIntegrity = true,
|
||||
includeChecksums = true
|
||||
} = { ...config, ...options };
|
||||
} = {
|
||||
destinationPath: config.database_backup_destination_path,
|
||||
compress: config.database_backup_compress,
|
||||
validateIntegrity: config.database_backup_validate_integrity,
|
||||
includeChecksums: config.database_backup_include_checksums,
|
||||
...options
|
||||
};
|
||||
|
||||
if (isUnderPubliclyServableRoot(destinationPath)) {
|
||||
throw new Error(
|
||||
`Refusing to write a database backup to a publicly served directory: ${destinationPath}`
|
||||
);
|
||||
}
|
||||
|
||||
// Create backup directory
|
||||
await fs.mkdir(destinationPath, { recursive: true });
|
||||
|
||||
@@ -423,7 +511,7 @@ class DatabaseBackupService {
|
||||
logger.info(`Database backup completed: ${finalFile} (${(finalStats.size / 1024 / 1024).toFixed(2)} MB) in ${durationSeconds}s`);
|
||||
|
||||
// Send success notification if configured
|
||||
if (config.emailOnSuccess) {
|
||||
if (config.database_backup_email_on_success) {
|
||||
await this.sendBackupNotification('success', {
|
||||
duration: durationSeconds,
|
||||
size: finalStats.size,
|
||||
@@ -457,7 +545,7 @@ class DatabaseBackupService {
|
||||
|
||||
// Send failure notification
|
||||
const config = await this.getBackupConfig();
|
||||
if (config.emailOnFailure) {
|
||||
if (config.database_backup_email_on_failure) {
|
||||
await this.sendBackupNotification('failure', {
|
||||
error: error.message
|
||||
});
|
||||
@@ -538,10 +626,19 @@ class DatabaseBackupService {
|
||||
* Clean up old backups
|
||||
*/
|
||||
async cleanupOldBackups(retentionDays = 30) {
|
||||
// A zero/negative/non-finite value pushes the cutoff to today or the
|
||||
// future, matching (and deleting) every completed backup — including
|
||||
// the one a scheduled run just created. Defense in depth: PUT /config
|
||||
// already rejects such values, but this is also reachable with
|
||||
// whatever database_backup_retention_days happens to be persisted.
|
||||
if (!Number.isFinite(retentionDays) || retentionDays < 1) {
|
||||
logger.error(`Refusing to clean up backups with invalid retentionDays: ${retentionDays}`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const cutoffDate = new Date();
|
||||
cutoffDate.setDate(cutoffDate.getDate() - retentionDays);
|
||||
|
||||
|
||||
// Get old backup records
|
||||
const oldBackups = await db('database_backup_runs')
|
||||
.where('completed_at', '<', cutoffDate)
|
||||
@@ -686,25 +783,30 @@ async function startScheduledBackups() {
|
||||
|
||||
try {
|
||||
const config = await databaseBackupService.getBackupConfig();
|
||||
|
||||
if (!config.enabled) {
|
||||
|
||||
if (!config.database_backup_enabled) {
|
||||
logger.info('Database backup service is disabled');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Stop existing schedule
|
||||
if (backupSchedule) {
|
||||
backupSchedule.stop();
|
||||
}
|
||||
|
||||
|
||||
// Default schedule: 3 AM daily (offset from file backups at 2 AM)
|
||||
const schedule = config.schedule || '0 3 * * *';
|
||||
|
||||
const schedule = config.database_backup_schedule || '0 3 * * *';
|
||||
|
||||
backupSchedule = cron.schedule(schedule, async () => {
|
||||
logger.info('Starting scheduled database backup');
|
||||
try {
|
||||
await databaseBackupService.backup();
|
||||
await databaseBackupService.cleanupOldBackups(config.retentionDays || 30);
|
||||
// Re-read retention on every tick rather than closing over the value
|
||||
// from schedule start — a retention-only /config update doesn't
|
||||
// restart the schedule (only enabled/schedule changes do), so the
|
||||
// closed-over value would otherwise run stale until next restart.
|
||||
const latestConfig = await databaseBackupService.getBackupConfig();
|
||||
await databaseBackupService.cleanupOldBackups(latestConfig.database_backup_retention_days || 30);
|
||||
} catch (error) {
|
||||
logger.error('Scheduled database backup failed:', error);
|
||||
}
|
||||
@@ -731,5 +833,6 @@ module.exports = {
|
||||
databaseBackupService,
|
||||
startScheduledBackups,
|
||||
stopScheduledBackups,
|
||||
isUnderPubliclyServableRoot,
|
||||
DatabaseBackupService // Export class for testing
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "picpeak-frontend",
|
||||
"private": true,
|
||||
"version": "3.46.10",
|
||||
"version": "3.46.11",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
Reference in New Issue
Block a user