9050affd8d
* fix(security): stop unauth share_token leak + block restore path-traversal, logo-path file read, branding path keys * test: update resolveLogoFile for the c7x5 containment (reject outside-storage absolute paths, keep inside) * fix(security): codex round-1 — escape LIKE wildcards in share-link resolve, keep in-storage absolute logos, guard restore verification - shareLinkService: escape %/_ in the link_partial LIKE fallback so an anonymous /resolve/____… wildcard can't match an arbitrary share_link and leak its bearer token (reopened GHSA-rh8r). Explicit ESCAPE for SQLite. - resolveLogoFile: re-add the raw absolute candidate but keep it subject to the storage-root containment filter (GHSA-c7x5) so legit in-storage absolute logos resolve while /etc/passwd stays rejected. - restoreService: apply the same pathEscapes guard in post-restore verification so a skipped traversal entry isn't fs.access'd/hashed. --------- Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
88 lines
3.6 KiB
JavaScript
88 lines
3.6 KiB
JavaScript
/**
|
|
* GHSA-rh8r-7x3h-36rv — the unauthenticated GET /api/gallery/resolve/:identifier
|
|
* must NOT return a gallery's secret share_token (nor the share links that
|
|
* embed it) for a bare *slug* lookup. Slugs appear in gallery URLs and are
|
|
* guessable; handing back the secret turns a known slug into share-link
|
|
* access to a no-password gallery. The token is only returned when the caller
|
|
* resolved via the token / full share link (i.e. already holds it).
|
|
*/
|
|
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-resolve-')), 'db.sqlite',
|
|
);
|
|
process.env.JWT_SECRET = process.env.JWT_SECRET || 'resolve-test-secret';
|
|
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-resolve-storage-'));
|
|
|
|
const request = require('supertest');
|
|
const express = require('express');
|
|
const cookieParser = require('cookie-parser');
|
|
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
|
|
|
|
const SLUG = 'resolve-test-event';
|
|
const SHARE_TOKEN = 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6';
|
|
|
|
describe('GET /api/gallery/resolve/:identifier (GHSA-rh8r)', () => {
|
|
let db; let cleanup; let app;
|
|
|
|
beforeAll(async () => {
|
|
({ db, cleanup } = await bootCrmDb());
|
|
await seedMinimal(db);
|
|
await db('events').insert({
|
|
slug: SLUG,
|
|
event_type: 'wedding',
|
|
event_name: 'Resolve Test',
|
|
event_date: '2026-08-01',
|
|
host_email: 'h@example.com',
|
|
admin_email: 'a@example.com',
|
|
password_hash: 'x',
|
|
share_link: `/gallery/${SLUG}/${SHARE_TOKEN}`,
|
|
share_token: SHARE_TOKEN,
|
|
require_password: 0, // no-password → the token IS the access credential
|
|
expires_at: new Date(Date.now() + 7 * 864e5).toISOString(),
|
|
is_active: 1, is_archived: 0, is_draft: 0,
|
|
created_at: new Date().toISOString(),
|
|
});
|
|
|
|
app = express();
|
|
app.use(express.json());
|
|
app.use(cookieParser());
|
|
app.use('/api/gallery', require('../../src/routes/gallery'));
|
|
}, 120000);
|
|
|
|
afterAll(async () => { if (cleanup) await cleanup(); });
|
|
|
|
it('does NOT leak the share_token (or share links) for a bare slug lookup', async () => {
|
|
const res = await request(app).get(`/api/gallery/resolve/${SLUG}`);
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.slug).toBe(SLUG);
|
|
expect(res.body.matchType).toBe('slug');
|
|
// The secret must be absent — and must not sneak out via the share links.
|
|
expect(res.body.token).toBeUndefined();
|
|
expect(res.body.share_link).toBeUndefined();
|
|
expect(res.body.share_url).toBeUndefined();
|
|
expect(JSON.stringify(res.body)).not.toContain(SHARE_TOKEN);
|
|
});
|
|
|
|
it('DOES return the token when the caller already resolved via the token', async () => {
|
|
const res = await request(app).get(`/api/gallery/resolve/${SHARE_TOKEN}`);
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.token).toBe(SHARE_TOKEN);
|
|
expect(res.body.matchType).toMatch(/token/);
|
|
});
|
|
|
|
it('does NOT leak the token via SQL LIKE wildcards in the link_partial fallback', async () => {
|
|
// Before the escaping fix, an anonymous request of 32 underscores matched
|
|
// any share_link ending in a 32-char token (`_` = single-char wildcard),
|
|
// resolved as matchType 'link_partial', and handed back the bearer token.
|
|
// The share_token here has no underscores, so an escaped LIKE must miss.
|
|
const res = await request(app).get(`/api/gallery/resolve/${'_'.repeat(SHARE_TOKEN.length)}`);
|
|
expect(res.status).toBe(404);
|
|
expect(res.body.token).toBeUndefined();
|
|
expect(JSON.stringify(res.body)).not.toContain(SHARE_TOKEN);
|
|
});
|
|
});
|