fix(security): close GHSA-g94x (cross-gallery photo read) + GHSA-pv6w (admin DB export) (stable) (#925)
* fix(security): close two access-control advisories (GHSA-g94x, GHSA-pv6w) (stable) 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. Stable port of #924. secureImages on stable has no reveal-mode block, so only the token-binding checks are added; the backup export gate is identical. * 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 <[email protected]>
This commit is contained in:
co-authored by
Paul Nothaft
parent
a27d19b4d1
commit
60cbda5b22
@@ -0,0 +1,95 @@
|
|||||||
|
/**
|
||||||
|
* Full-instance export is super_admin only (GHSA-pv6w-rj34-wj9v).
|
||||||
|
*
|
||||||
|
* GET /api/admin/backup/picpeak/export dumps every table unredacted (bcrypt
|
||||||
|
* hashes, 2FA, SMTP/SSO/WhatsApp/webhook/S3 secrets). It was gated only by
|
||||||
|
* requirePermission('backup.create'), which the built-in `admin` role holds —
|
||||||
|
* so any non-super_admin admin could download the whole database. Pins that
|
||||||
|
* `admin` now gets 403 and `super_admin` passes the gate.
|
||||||
|
*/
|
||||||
|
|
||||||
|
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-bkexport-')), 'db.sqlite',
|
||||||
|
);
|
||||||
|
process.env.JWT_SECRET = process.env.JWT_SECRET || 'bkexport-test-secret';
|
||||||
|
|
||||||
|
// The export otherwise walks the whole DB and writes a zip — stub it so the
|
||||||
|
// super_admin happy path is fast and deterministic; the gate is what's tested.
|
||||||
|
// The route deletes path.dirname(filePath) recursively after download, so the
|
||||||
|
// stub MUST live in its own dir — a bare os.tmpdir() file would make the route
|
||||||
|
// wipe the whole temp root (and other jest workers' DB files).
|
||||||
|
const mockExportDir = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-export-stub-'));
|
||||||
|
const mockExportPath = path.join(mockExportDir, 'export.picpeak');
|
||||||
|
fs.writeFileSync(mockExportPath, 'stub');
|
||||||
|
jest.mock('../../src/services/picpeakExportService', () => ({
|
||||||
|
createPicpeak: jest.fn(async () => ({ filePath: mockExportPath })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const request = require('supertest');
|
||||||
|
const express = require('express');
|
||||||
|
const bcrypt = require('bcrypt');
|
||||||
|
const jwt = require('jsonwebtoken');
|
||||||
|
|
||||||
|
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
|
||||||
|
|
||||||
|
describe('backup export super_admin gate (GHSA-pv6w)', () => {
|
||||||
|
let db;
|
||||||
|
let cleanup;
|
||||||
|
let app;
|
||||||
|
let adminToken; let superToken;
|
||||||
|
|
||||||
|
const mkUser = async (username, roleName) => {
|
||||||
|
const role = await db('roles').where({ name: roleName }).first();
|
||||||
|
const r = await db('admin_users').insert({
|
||||||
|
username,
|
||||||
|
email: `${username}@example.com`,
|
||||||
|
password_hash: await bcrypt.hash('Passw0rd!', 4),
|
||||||
|
role_id: role.id,
|
||||||
|
is_active: 1,
|
||||||
|
created_at: new Date(),
|
||||||
|
updated_at: new Date(),
|
||||||
|
}).returning('id');
|
||||||
|
const id = r[0]?.id ?? r[0];
|
||||||
|
return jwt.sign(
|
||||||
|
{ id, username, type: 'admin', role: roleName, loginTime: Date.now() },
|
||||||
|
process.env.JWT_SECRET,
|
||||||
|
{ expiresIn: '1h', issuer: 'picpeak-auth' },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
({ db, cleanup } = await bootCrmDb());
|
||||||
|
await seedMinimal(db);
|
||||||
|
adminToken = await mkUser('limited-admin', 'admin');
|
||||||
|
superToken = await mkUser('root-admin', 'super_admin');
|
||||||
|
|
||||||
|
app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use('/api/admin/backup', require('../../src/routes/adminBackup'));
|
||||||
|
}, 120000);
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
if (cleanup) await cleanup();
|
||||||
|
fs.rmSync(mockExportDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('denies the built-in admin role (was: full DB dump)', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/admin/backup/picpeak/export')
|
||||||
|
.set('Authorization', `Bearer ${adminToken}`);
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows super_admin', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/admin/backup/picpeak/export')
|
||||||
|
.set('Authorization', `Bearer ${superToken}`);
|
||||||
|
expect(res.status).not.toBe(403);
|
||||||
|
expect(res.status).toBeLessThan(500);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
/**
|
||||||
|
* Secure-image view route token binding (GHSA-g94x-8vv8-3c9f).
|
||||||
|
*
|
||||||
|
* The view route GET /api/secure-images/:slug/secure/:photoId/:token serves
|
||||||
|
* via <img src> with the token in the URL, so it can't carry a gallery-token
|
||||||
|
* header like the download sibling. Before the fix it 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.
|
||||||
|
*
|
||||||
|
* Pins that the route now enforces the scope inside the token:
|
||||||
|
* - the URL photoId must equal the token's minted photoId
|
||||||
|
* - the gallery embedded in the token's sessionId must equal the URL gallery
|
||||||
|
* A token minted on gallery A cannot read gallery B under either check; a
|
||||||
|
* token used on its own gallery+photo passes the binding.
|
||||||
|
*/
|
||||||
|
|
||||||
|
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-secimg-')), 'db.sqlite',
|
||||||
|
);
|
||||||
|
process.env.JWT_SECRET = process.env.JWT_SECRET || 'secimg-test-secret';
|
||||||
|
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-secimg-storage-'));
|
||||||
|
|
||||||
|
// Stub the anti-bot/rate-limit middleware so the fingerprint is deterministic
|
||||||
|
// — the token below is minted with the same fingerprint, so verifySecureToken
|
||||||
|
// passes and the binding logic under test is what decides the outcome.
|
||||||
|
jest.mock('../../src/middleware/secureImageMiddleware', () => ({
|
||||||
|
secureImageAccess: (req, _res, next) => {
|
||||||
|
req.clientInfo = { fingerprint: 'test-fp', ip: '127.0.0.1', userAgent: 'jest' };
|
||||||
|
next();
|
||||||
|
},
|
||||||
|
getSecurityStatus: (_req, res) => res.json({ ok: true }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const request = require('supertest');
|
||||||
|
const express = require('express');
|
||||||
|
|
||||||
|
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
|
||||||
|
const secureImageService = require('../../src/services/secureImageService');
|
||||||
|
|
||||||
|
describe('secure-image view route token binding (GHSA-g94x)', () => {
|
||||||
|
let db;
|
||||||
|
let cleanup;
|
||||||
|
let app;
|
||||||
|
let galleryA; let galleryB;
|
||||||
|
let photoA; let photoB;
|
||||||
|
|
||||||
|
const mkEvent = async (slug, requirePassword) => {
|
||||||
|
const r = await db('events').insert({
|
||||||
|
slug,
|
||||||
|
event_type: 'wedding',
|
||||||
|
event_name: slug,
|
||||||
|
event_date: '2026-08-01',
|
||||||
|
host_email: '[email protected]',
|
||||||
|
admin_email: '[email protected]',
|
||||||
|
password_hash: 'x',
|
||||||
|
require_password: requirePassword ? 1 : 0,
|
||||||
|
share_link: `/gallery/${slug}/share`,
|
||||||
|
share_token: `${slug}-share`,
|
||||||
|
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
|
||||||
|
is_active: 1,
|
||||||
|
is_archived: 0,
|
||||||
|
is_draft: 0,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
}).returning('id');
|
||||||
|
return r[0]?.id ?? r[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
const mkPhoto = async (eventId, slug, filename) => {
|
||||||
|
const dir = path.join(process.env.STORAGE_PATH, 'events/active', slug);
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(dir, filename), Buffer.from('img'));
|
||||||
|
const r = await db('photos').insert({
|
||||||
|
event_id: eventId,
|
||||||
|
filename,
|
||||||
|
path: `${slug}/${filename}`,
|
||||||
|
type: 'individual',
|
||||||
|
uploaded_at: new Date().toISOString(),
|
||||||
|
}).returning('id');
|
||||||
|
return r[0]?.id ?? r[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mint a token exactly as the mint route does — bound to (photoId, gallery
|
||||||
|
// sessionId, fingerprint) — bypassing the anti-bot HTTP path.
|
||||||
|
const mint = (photoId, eventId) => secureImageService.generateSecureToken(
|
||||||
|
photoId,
|
||||||
|
`gallery_public_${eventId}_${Date.now()}`,
|
||||||
|
{ clientFingerprint: 'test-fp', maxUses: 100, expiresIn: 3600 },
|
||||||
|
);
|
||||||
|
|
||||||
|
const view = (slug, photoId, token) => request(app)
|
||||||
|
.get(`/api/secure-images/${slug}/secure/${photoId}/${token}`);
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
({ db, cleanup } = await bootCrmDb());
|
||||||
|
await seedMinimal(db);
|
||||||
|
galleryA = await mkEvent('secimg-public-a', false); // public — token source
|
||||||
|
galleryB = await mkEvent('secimg-private-b', true); // password-protected — victim
|
||||||
|
photoA = await mkPhoto(galleryA, 'secimg-public-a', 'a.jpg');
|
||||||
|
photoB = await mkPhoto(galleryB, 'secimg-private-b', 'b.jpg');
|
||||||
|
|
||||||
|
app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use('/api/secure-images', require('../../src/routes/secureImages'));
|
||||||
|
}, 120000);
|
||||||
|
|
||||||
|
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||||
|
|
||||||
|
it('rejects a gallery-A token used against gallery B (cross-photo)', async () => {
|
||||||
|
const token = mint(photoA, galleryA);
|
||||||
|
const res = await view('secimg-private-b', photoB, token);
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
expect(res.body.error).toMatch(/not valid for this photo/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a gallery-A token replayed on gallery B with A\'s photoId', async () => {
|
||||||
|
const token = mint(photoA, galleryA);
|
||||||
|
// URL photoId matches the token, so the photo check passes — the gallery
|
||||||
|
// check (sessionId gallery A != URL gallery B) must catch it.
|
||||||
|
const res = await view('secimg-private-b', photoA, token);
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
expect(res.body.error).toMatch(/not valid for this gallery/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets a token read its own gallery + photo (binding passes)', async () => {
|
||||||
|
const token = mint(photoA, galleryA);
|
||||||
|
const res = await view('secimg-public-a', photoA, token);
|
||||||
|
// Binding passes; serving may 200/404/500 depending on the pipeline, but
|
||||||
|
// it must NOT be rejected as a token mismatch.
|
||||||
|
expect(res.status).not.toBe(403);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { db } = require('../database/db');
|
const { db } = require('../database/db');
|
||||||
const { adminAuth } = require('../middleware/auth');
|
const { adminAuth } = require('../middleware/auth');
|
||||||
const { requirePermission } = require('../middleware/permissions');
|
const { requirePermission, requireSuperAdmin } = require('../middleware/permissions');
|
||||||
const { clearAdminAuthCookie } = require('../utils/tokenUtils');
|
const { clearAdminAuthCookie } = require('../utils/tokenUtils');
|
||||||
const { revokeToken } = require('../utils/tokenRevocation');
|
const { revokeToken } = require('../utils/tokenRevocation');
|
||||||
const { triggerManualBackup, getBackupStatus, cleanupOldBackupRuns, getBackupManifest, validateBackupManifest } = require('../services/backupService');
|
const { triggerManualBackup, getBackupStatus, cleanupOldBackupRuns, getBackupManifest, validateBackupManifest } = require('../services/backupService');
|
||||||
@@ -139,7 +139,12 @@ router.post('/run', adminAuth, requirePermission('backup.create'), async (req, r
|
|||||||
// SECURITY: the file contains plaintext secrets (SMTP password, admin password
|
// SECURITY: the file contains plaintext secrets (SMTP password, admin password
|
||||||
// hashes, API keys). The download UI must warn before offering it. We surface
|
// hashes, API keys). The download UI must warn before offering it. We surface
|
||||||
// the flag as a response header too so the client can double-confirm.
|
// the flag as a response header too so the client can double-confirm.
|
||||||
router.get('/picpeak/export', adminAuth, requirePermission('backup.create'), async (req, res) => {
|
// Full-instance export dumps every table unredacted — bcrypt password
|
||||||
|
// hashes, 2FA columns, and all integration secrets (SMTP/SSO/WhatsApp/
|
||||||
|
// webhook/S3) in cleartext. The built-in `admin` role holds backup.create,
|
||||||
|
// but is denied this data everywhere else (config APIs mask secrets as
|
||||||
|
// ********). Gate the raw dump behind super_admin (GHSA-pv6w-rj34-wj9v).
|
||||||
|
router.get('/picpeak/export', adminAuth, requireSuperAdmin(), async (req, res) => {
|
||||||
const fsSync = require('fs');
|
const fsSync = require('fs');
|
||||||
try {
|
try {
|
||||||
const includePhotos = req.query.includePhotos === 'true' || req.query.includePhotos === '1';
|
const includePhotos = req.query.includePhotos === 'true' || req.query.includePhotos === '1';
|
||||||
|
|||||||
@@ -137,6 +137,34 @@ router.get('/:slug/secure/:photoId/:token',
|
|||||||
return res.status(404).json({ error: 'Gallery not found' });
|
return res.status(404).json({ error: 'Gallery not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bind the token to the gallery + photo it was minted for
|
||||||
|
// (GHSA-g94x-8vv8-3c9f). This route serves via <img src> with the
|
||||||
|
// token in the URL, so it can't require verifyGalleryAccess like the
|
||||||
|
// download sibling does. Instead enforce the scope already inside the
|
||||||
|
// token: it is minted for one photoId (and photos belong to exactly
|
||||||
|
// one gallery), and its sessionId records the minting gallery's id.
|
||||||
|
// Without this, a token minted on any PUBLIC gallery reads every other
|
||||||
|
// gallery's photos with no password.
|
||||||
|
const tokenPhotoId = Number(tokenValidation.data?.photoId);
|
||||||
|
if (!Number.isInteger(tokenPhotoId) || tokenPhotoId !== Number(photoId)) {
|
||||||
|
await secureImageService.logImageAccess(
|
||||||
|
photoId, event.id, req.clientInfo, 'photo_mismatch'
|
||||||
|
);
|
||||||
|
return res.status(403).json({ error: 'Token not valid for this photo' });
|
||||||
|
}
|
||||||
|
// Defense in depth: the sessionId embeds the gallery the token was
|
||||||
|
// minted for (`gallery_public_<id>_...` / `gallery_<id>_...`). Reject a
|
||||||
|
// token whose gallery is parseable and differs from this one.
|
||||||
|
const sessionEventId = Number(
|
||||||
|
(String(tokenValidation.data?.sessionId || '').match(/^gallery_(?:public_)?(\d+)_/) || [])[1]
|
||||||
|
);
|
||||||
|
if (Number.isInteger(sessionEventId) && sessionEventId !== Number(event.id)) {
|
||||||
|
await secureImageService.logImageAccess(
|
||||||
|
photoId, event.id, req.clientInfo, 'gallery_mismatch'
|
||||||
|
);
|
||||||
|
return res.status(403).json({ error: 'Token not valid for this gallery' });
|
||||||
|
}
|
||||||
|
|
||||||
// Verify photo exists and belongs to event
|
// Verify photo exists and belongs to event
|
||||||
const photo = await db('photos')
|
const photo = await db('photos')
|
||||||
.where({ id: photoId, event_id: event.id })
|
.where({ id: photoId, event_id: event.id })
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { Button, Card, Loading } from '../../components/common';
|
import { Button, Card, Loading } from '../../components/common';
|
||||||
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
|
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
|
||||||
import { useMutationWithToast } from '../../hooks';
|
import { useMutationWithToast } from '../../hooks';
|
||||||
|
import { useAdminAuth } from '../../contexts/AdminAuthContext';
|
||||||
import { BackupDashboard } from '../../components/admin/BackupDashboard';
|
import { BackupDashboard } from '../../components/admin/BackupDashboard';
|
||||||
import { BackupConfiguration } from '../../components/admin/BackupConfiguration';
|
import { BackupConfiguration } from '../../components/admin/BackupConfiguration';
|
||||||
import { BackupHistory } from '../../components/admin/BackupHistory';
|
import { BackupHistory } from '../../components/admin/BackupHistory';
|
||||||
@@ -33,6 +34,11 @@ type TabId = 'dashboard' | 'configuration' | 'history' | 'restore' | 'integrity'
|
|||||||
export const BackupManagement: React.FC = () => {
|
export const BackupManagement: React.FC = () => {
|
||||||
const [activeTab, setActiveTab] = useState<TabId>('dashboard');
|
const [activeTab, setActiveTab] = useState<TabId>('dashboard');
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
// Full-instance export contains every secret, so the endpoint is
|
||||||
|
// super_admin-only (GHSA-pv6w) — hide the card for other roles instead
|
||||||
|
// of showing a button that always 403s.
|
||||||
|
const { user } = useAdminAuth();
|
||||||
|
const isSuperAdmin = user?.role?.name === 'super_admin';
|
||||||
const { formatDateTime: fmtDateTime } = useLocalizedDate();
|
const { formatDateTime: fmtDateTime } = useLocalizedDate();
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
@@ -201,7 +207,7 @@ export const BackupManagement: React.FC = () => {
|
|||||||
onRunBackup={() => manualBackupMutation.mutate()}
|
onRunBackup={() => manualBackupMutation.mutate()}
|
||||||
isBackupRunning={backupStatus?.isRunning || manualBackupMutation.isPending}
|
isBackupRunning={backupStatus?.isRunning || manualBackupMutation.isPending}
|
||||||
/>
|
/>
|
||||||
<PicpeakExportCard />
|
{isSuperAdmin && <PicpeakExportCard />}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user