03087c798c
* fix(security): close two access-control advisories (GHSA-g94x, GHSA-pv6w) GHSA-g94x-8vv8-3c9f (HIGH) — the secure-image VIEW route (/secure-images/:slug/secure/:photoId/:token) validated only the token signature and took the gallery/photo from the URL, so a token minted on any PUBLIC gallery read every other gallery's photos with no password (its download sibling has verifyGalleryAccess; the view route can't — it serves via <img src> with no header). Bind the token to its scope instead: the URL photoId must equal the token's minted photoId (photos belong to exactly one gallery, and minting is gallery-scoped), and the gallery embedded in the token's sessionId must equal the URL gallery. GHSA-pv6w-rj34-wj9v (MEDIUM) — GET /admin/backup/picpeak/export dumps every table unredacted (bcrypt hashes, 2FA, SMTP/SSO/WhatsApp/webhook/S3 secrets) and was gated only by backup.create, which the built-in admin role holds. Gate it behind super_admin, matching the restore side (backup.restore, already admin-denied) and the masked config APIs. Regression tests pin both: cross-gallery token reads 403 (photo and gallery checks), backup export 403 for admin / passes for super_admin. * test: stub requireSuperAdmin in the backup masking mock adminBackup now calls requireSuperAdmin() at load (GHSA-pv6w export gate), and backupSecretMasking mocks the permissions module — add the new function to the mock so the module loads. * fix(security): review follow-ups on the export gate (GHSA-pv6w) - test: place the mocked export in its own mkdtemp dir. The route recursively deletes path.dirname(filePath) after download, so a stub in bare os.tmpdir() made the super_admin test wipe the whole temp root — other jest workers' DB files included (latent CI flake). - ui: hide PicpeakExportCard from non-super_admins. The role keeps settings.view + backup.create, so after the gate its Download button always 403'd with a generic toast; gate the card on role super_admin to match the endpoint. * fix(security): keep the token-mismatch audit values within varchar(20) (GHSA-g94x review) image_access_logs.access_type is varchar(20) (migration 038), but 'token_gallery_mismatch' is 22 chars — on Postgres the audit write threw value-too-long and logImageAccess swallowed it, so the security event went unrecorded (the 403 still fired; log is best-effort). Shorten to 'photo_mismatch' / 'gallery_mismatch' (14/16). --------- Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
110 lines
4.7 KiB
JavaScript
110 lines
4.7 KiB
JavaScript
/**
|
|
* Backup credential exposure regression tests.
|
|
*
|
|
* The generic settings reads (GET /admin/settings, GET /admin/settings/:type)
|
|
* masked the recaptcha/umami/rybbit keys but returned backup_s3_secret_key
|
|
* and backup_rsync_ssh_key (an SSH PRIVATE KEY) in plaintext to any
|
|
* settings.view holder; GET /admin/backup/config returned them too. Both now
|
|
* mask, and PUT /admin/backup/config skips the mask sentinel so the edit
|
|
* form round-trips without clobbering stored credentials.
|
|
*/
|
|
|
|
const request = require('supertest');
|
|
const express = require('express');
|
|
|
|
const { bootCrmDb } = require('./helpers/crmDb');
|
|
|
|
jest.mock('../../src/middleware/auth', () => ({
|
|
adminAuth: (req, _res, next) => {
|
|
req.admin = { id: 1, username: 'test-admin' };
|
|
next();
|
|
},
|
|
}));
|
|
jest.mock('../../src/middleware/permissions', () => ({
|
|
requirePermission: () => (_req, _res, next) => next(),
|
|
requireSuperAdmin: () => (_req, _res, next) => next(),
|
|
}));
|
|
|
|
describe('backup credential masking', () => {
|
|
let db;
|
|
let cleanup;
|
|
let app;
|
|
|
|
beforeAll(async () => {
|
|
({ db, cleanup } = await bootCrmDb());
|
|
|
|
// Upsert: several backup_* keys are pre-seeded by the backup migrations.
|
|
const seed = [
|
|
{ setting_key: 'backup_destination_type', setting_value: JSON.stringify('s3'), setting_type: 'backup' },
|
|
{ setting_key: 'backup_s3_endpoint', setting_value: JSON.stringify('https://s3.example.com'), setting_type: 'backup' },
|
|
{ setting_key: 'backup_s3_bucket', setting_value: JSON.stringify('backups'), setting_type: 'backup' },
|
|
{ setting_key: 'backup_s3_access_key', setting_value: JSON.stringify('AKIAEXAMPLE'), setting_type: 'backup' },
|
|
{ setting_key: 'backup_s3_secret_key', setting_value: JSON.stringify('super-secret-s3-key'), setting_type: 'backup' },
|
|
{ setting_key: 'backup_rsync_ssh_key', setting_value: JSON.stringify('-----BEGIN OPENSSH PRIVATE KEY-----abc'), setting_type: 'backup' },
|
|
];
|
|
for (const row of seed) {
|
|
await db('app_settings').insert(row).onConflict('setting_key').merge();
|
|
}
|
|
|
|
app = express();
|
|
app.use(express.json());
|
|
app.use('/api/admin/backup', require('../../src/routes/adminBackup'));
|
|
app.use('/api/admin/settings', require('../../src/routes/adminSettings'));
|
|
}, 120000);
|
|
|
|
afterAll(async () => {
|
|
if (cleanup) await cleanup();
|
|
});
|
|
|
|
it('masks the credentials in GET /admin/backup/config', async () => {
|
|
const res = await request(app).get('/api/admin/backup/config').expect(200);
|
|
expect(res.body.backup_s3_secret_key).toBe('••••••••');
|
|
expect(res.body.backup_rsync_ssh_key).toBe('••••••••');
|
|
// Non-secret fields stay readable for the form.
|
|
expect(res.body.backup_s3_bucket).toBe('backups');
|
|
});
|
|
|
|
it('masks the credentials in the generic GET /admin/settings/:type read', async () => {
|
|
const res = await request(app).get('/api/admin/settings/backup').expect(200);
|
|
expect(res.body.backup_s3_secret_key).toBe('••••••••');
|
|
expect(res.body.backup_rsync_ssh_key).toBe('••••••••');
|
|
});
|
|
|
|
it('masks the credentials in the generic GET /admin/settings read', async () => {
|
|
const res = await request(app).get('/api/admin/settings').expect(200);
|
|
expect(res.body.backup_s3_secret_key).toBe('••••••••');
|
|
expect(res.body.backup_rsync_ssh_key).toBe('••••••••');
|
|
});
|
|
|
|
it('PUT /admin/backup/config keeps the stored secret when the sentinel round-trips', async () => {
|
|
await request(app)
|
|
.put('/api/admin/backup/config')
|
|
.send({
|
|
backup_destination_type: 's3',
|
|
backup_s3_endpoint: 'https://s3.example.com',
|
|
backup_s3_bucket: 'renamed-bucket',
|
|
backup_s3_access_key: 'AKIAEXAMPLE',
|
|
backup_s3_secret_key: '••••••••',
|
|
backup_rsync_ssh_key: '••••••••',
|
|
})
|
|
.expect(200);
|
|
|
|
const secret = await db('app_settings').where({ setting_key: 'backup_s3_secret_key' }).first();
|
|
expect(JSON.parse(secret.setting_value)).toBe('super-secret-s3-key');
|
|
const sshKey = await db('app_settings').where({ setting_key: 'backup_rsync_ssh_key' }).first();
|
|
expect(JSON.parse(sshKey.setting_value)).toBe('-----BEGIN OPENSSH PRIVATE KEY-----abc');
|
|
const bucket = await db('app_settings').where({ setting_key: 'backup_s3_bucket' }).first();
|
|
expect(JSON.parse(bucket.setting_value)).toBe('renamed-bucket');
|
|
});
|
|
|
|
it('PUT /admin/backup/config stores a genuinely new secret', async () => {
|
|
await request(app)
|
|
.put('/api/admin/backup/config')
|
|
.send({ backup_s3_secret_key: 'rotated-s3-key' })
|
|
.expect(200);
|
|
|
|
const secret = await db('app_settings').where({ setting_key: 'backup_s3_secret_key' }).first();
|
|
expect(JSON.parse(secret.setting_value)).toBe('rotated-s3-key');
|
|
});
|
|
});
|