8a87c9274b
* fix(security): block guest access to hidden/client-only photos across bulk + secure routes * fix(security): harden hidden-photo fix per review (stale ZIP cache, legacy token mint, SQLite bool, client rebuild) * fix(security): invalidate ZIP cache on photo visibility/category change (codex r2) * fix(security): recheck photo visibility at signed/secure serve time (TOCTOU) + invalidate ZIP on client visibility change (codex r3) --------- Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
241 lines
9.3 KiB
JavaScript
241 lines
9.3 KiB
JavaScript
/**
|
|
* Hidden/client-only photo access control across the bulk + secure photo
|
|
* routes (GHSA cluster: fpwq / ghf8 / 3jvw / 9cc4 / 2hqg / jc22).
|
|
*
|
|
* A photo with visibility='hidden' is client-only. The main photo-list and
|
|
* single-photo download/view routes enforced this, but the bulk-download,
|
|
* protected-image, and secure-image routes shipped without the check —
|
|
* letting an ordinary guest reach hidden photos. These tests pin that
|
|
* guests are refused and PIN-clients (accessLevel='client') still succeed.
|
|
*/
|
|
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-hidden-')), 'db.sqlite',
|
|
);
|
|
process.env.JWT_SECRET = process.env.JWT_SECRET || 'hidden-photo-test-secret';
|
|
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-hidden-storage-'));
|
|
|
|
const request = require('supertest');
|
|
const express = require('express');
|
|
const cookieParser = require('cookie-parser');
|
|
const jwt = require('jsonwebtoken');
|
|
|
|
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
|
|
|
|
const SLUG = 'hidden-photo-test-event';
|
|
|
|
describe('hidden-photo access control (GHSA cluster)', () => {
|
|
let db;
|
|
let cleanup;
|
|
let app;
|
|
let eventId;
|
|
let visibleId;
|
|
let hiddenId;
|
|
|
|
const guestToken = () => jwt.sign(
|
|
{ eventId, eventSlug: SLUG, type: 'gallery' },
|
|
process.env.JWT_SECRET,
|
|
{ expiresIn: '1h', issuer: 'picpeak-auth' }
|
|
);
|
|
const clientToken = () => jwt.sign(
|
|
{ eventId, eventSlug: SLUG, type: 'gallery', accessLevel: 'client' },
|
|
process.env.JWT_SECRET,
|
|
{ expiresIn: '1h', issuer: 'picpeak-auth' }
|
|
);
|
|
|
|
beforeAll(async () => {
|
|
({ db, cleanup } = await bootCrmDb());
|
|
await seedMinimal(db);
|
|
|
|
const inserted = await db('events').insert({
|
|
slug: SLUG,
|
|
event_type: 'wedding',
|
|
event_name: 'Hidden Photo Test',
|
|
event_date: '2026-08-01',
|
|
host_email: 'host@example.com',
|
|
admin_email: 'admin@example.com',
|
|
password_hash: 'x',
|
|
share_link: `/gallery/${SLUG}/share`,
|
|
share_token: 'hidden-photo-share',
|
|
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
|
|
is_active: 1, is_archived: 0, is_draft: 0, allow_downloads: 1,
|
|
created_at: new Date().toISOString(),
|
|
}).returning('id');
|
|
eventId = inserted[0]?.id ?? inserted[0];
|
|
|
|
const photoDir = path.join(process.env.STORAGE_PATH, 'events/active', SLUG);
|
|
fs.mkdirSync(photoDir, { recursive: true });
|
|
|
|
// A real 1x1 PNG so the protected /view route's Sharp processing path
|
|
// succeeds (fake bytes 500 on metadata()). Content, not extension,
|
|
// drives Sharp's format detection.
|
|
const PNG_1x1 = Buffer.from(
|
|
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M8AAAMCAQGV2rY9AAAAAElFTkSuQmCC',
|
|
'base64'
|
|
);
|
|
const mkPhoto = async (filename, visibility) => {
|
|
fs.writeFileSync(path.join(photoDir, filename), PNG_1x1);
|
|
const p = await db('photos').insert({
|
|
event_id: eventId,
|
|
filename,
|
|
path: `${SLUG}/${filename}`,
|
|
type: 'individual',
|
|
visibility,
|
|
uploaded_at: new Date().toISOString(),
|
|
}).returning('id');
|
|
return p[0]?.id ?? p[0];
|
|
};
|
|
visibleId = await mkPhoto('visible.jpg', 'visible');
|
|
hiddenId = await mkPhoto('hidden.jpg', 'hidden');
|
|
|
|
app = express();
|
|
app.use(express.json());
|
|
app.use(cookieParser());
|
|
app.use('/api/gallery', require('../../src/routes/gallery'));
|
|
app.use('/api/images', require('../../src/routes/protectedImages'));
|
|
app.use('/api/secure-images', require('../../src/routes/secureImages'));
|
|
}, 120000);
|
|
|
|
afterAll(async () => { if (cleanup) await cleanup(); });
|
|
|
|
describe('download-selected (GHSA-ghf8, medium)', () => {
|
|
it('omits a hidden photo for a guest even when its id is requested', async () => {
|
|
const res = await request(app)
|
|
.post(`/api/gallery/${SLUG}/download-selected`)
|
|
.set('Authorization', `Bearer ${guestToken()}`)
|
|
.send({ photo_ids: [visibleId, hiddenId] });
|
|
// The visible photo still zips; the hidden one is filtered out. If
|
|
// only the hidden id were requested, the filter empties the set → 404.
|
|
expect(res.status).toBe(200);
|
|
const solo = await request(app)
|
|
.post(`/api/gallery/${SLUG}/download-selected`)
|
|
.set('Authorization', `Bearer ${guestToken()}`)
|
|
.send({ photo_ids: [hiddenId] });
|
|
expect(solo.status).toBe(404);
|
|
});
|
|
|
|
it('includes the hidden photo for a client', async () => {
|
|
const res = await request(app)
|
|
.post(`/api/gallery/${SLUG}/download-selected`)
|
|
.set('Authorization', `Bearer ${clientToken()}`)
|
|
.send({ photo_ids: [hiddenId] });
|
|
expect(res.status).toBe(200);
|
|
});
|
|
});
|
|
|
|
describe('download-all (GHSA-fpwq, medium)', () => {
|
|
it('streams for a guest without erroring (hidden photos filtered)', async () => {
|
|
const res = await request(app)
|
|
.get(`/api/gallery/${SLUG}/download-all`)
|
|
.set('Authorization', `Bearer ${guestToken()}`);
|
|
expect(res.status).toBe(200);
|
|
});
|
|
});
|
|
|
|
describe('protected-image view (GHSA-9cc4)', () => {
|
|
it('403s a hidden photo for a guest', async () => {
|
|
const res = await request(app)
|
|
.get(`/api/images/${SLUG}/photo/${hiddenId}/view`)
|
|
.set('Authorization', `Bearer ${guestToken()}`);
|
|
expect(res.status).toBe(403);
|
|
});
|
|
it('serves a visible photo for a guest', async () => {
|
|
const res = await request(app)
|
|
.get(`/api/images/${SLUG}/photo/${visibleId}/view`)
|
|
.set('Authorization', `Bearer ${guestToken()}`);
|
|
expect(res.status).toBe(200);
|
|
});
|
|
it('serves a hidden photo for a client', async () => {
|
|
const res = await request(app)
|
|
.get(`/api/images/${SLUG}/photo/${hiddenId}/view`)
|
|
.set('Authorization', `Bearer ${clientToken()}`);
|
|
expect(res.status).toBe(200);
|
|
});
|
|
});
|
|
|
|
describe('signed-URL mint (GHSA-3jvw)', () => {
|
|
it('403s minting a signed URL for a hidden photo as a guest', async () => {
|
|
const res = await request(app)
|
|
.post(`/api/images/${SLUG}/photo/${hiddenId}/generate-url`)
|
|
.set('Authorization', `Bearer ${guestToken()}`);
|
|
expect(res.status).toBe(403);
|
|
});
|
|
it('mints for a client', async () => {
|
|
const res = await request(app)
|
|
.post(`/api/images/${SLUG}/photo/${hiddenId}/generate-url`)
|
|
.set('Authorization', `Bearer ${clientToken()}`);
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.url).toContain('/signed/');
|
|
});
|
|
});
|
|
|
|
describe('legacy secure-token mint (protectedImages generate-secure-token)', () => {
|
|
it('403s a hidden photo for a guest', async () => {
|
|
const res = await request(app)
|
|
.post(`/api/images/${SLUG}/photo/${hiddenId}/generate-secure-token`)
|
|
.set('Authorization', `Bearer ${guestToken()}`);
|
|
expect(res.status).toBe(403);
|
|
});
|
|
it('mints for a client', async () => {
|
|
const res = await request(app)
|
|
.post(`/api/images/${SLUG}/photo/${hiddenId}/generate-secure-token`)
|
|
.set('Authorization', `Bearer ${clientToken()}`);
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.token).toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe('secure-token mint (GHSA-2hqg)', () => {
|
|
it('403s minting a secure token for a hidden photo as a guest', async () => {
|
|
const res = await request(app)
|
|
.post(`/api/secure-images/${SLUG}/generate-token`)
|
|
.set('Authorization', `Bearer ${guestToken()}`)
|
|
.send({ photoId: hiddenId });
|
|
expect(res.status).toBe(403);
|
|
});
|
|
it('mints for a client', async () => {
|
|
const res = await request(app)
|
|
.post(`/api/secure-images/${SLUG}/generate-token`)
|
|
.set('Authorization', `Bearer ${clientToken()}`)
|
|
.send({ photoId: hiddenId });
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.token).toBeDefined();
|
|
});
|
|
});
|
|
|
|
// A capability minted while a photo is visible must stop serving once the
|
|
// photo is hidden — unless minted by a client (clientBypass in the token).
|
|
describe('signed-URL TOCTOU (hidden AFTER minting)', () => {
|
|
afterEach(async () => {
|
|
await db('photos').where({ id: visibleId }).update({ visibility: 'visible' });
|
|
});
|
|
|
|
it("a guest's pre-minted signed URL stops serving once the photo is hidden", async () => {
|
|
const mint = await request(app)
|
|
.post(`/api/images/${SLUG}/photo/${visibleId}/generate-url`)
|
|
.set('Authorization', `Bearer ${guestToken()}`);
|
|
expect(mint.status).toBe(200);
|
|
const url = mint.body.url;
|
|
// Still visible → serves.
|
|
expect((await request(app).get(url)).status).toBe(200);
|
|
// Hide it → the guest token (no clientBypass) must now be refused.
|
|
await db('photos').where({ id: visibleId }).update({ visibility: 'hidden' });
|
|
expect((await request(app).get(url)).status).toBe(403);
|
|
});
|
|
|
|
it("a client's pre-minted signed URL keeps serving after the photo is hidden", async () => {
|
|
const mint = await request(app)
|
|
.post(`/api/images/${SLUG}/photo/${visibleId}/generate-url`)
|
|
.set('Authorization', `Bearer ${clientToken()}`);
|
|
expect(mint.status).toBe(200);
|
|
const url = mint.body.url;
|
|
await db('photos').where({ id: visibleId }).update({ visibility: 'hidden' });
|
|
expect((await request(app).get(url)).status).toBe(200);
|
|
});
|
|
});
|
|
});
|