diff --git a/backend/__tests__/routes/logoDiagnostic.test.js b/backend/__tests__/routes/logoDiagnostic.test.js new file mode 100644 index 00000000..12719f26 --- /dev/null +++ b/backend/__tests__/routes/logoDiagnostic.test.js @@ -0,0 +1,123 @@ +/** + * Logo diagnostic must not leak the filesystem layout, and must mirror what + * resolveLogoFile actually tries (GHSA-29vm, codex round 2). + * + * Round 1 relativised `resolvedTo` and the candidate paths but still echoed + * `sources[].value` verbatim — and branding_logo_path is stored ABSOLUTE by + * multer, so the layout went out anyway. It also dropped the raw-absolute + * candidate, which the resolver retains (subject to containment), making the + * diagnostic report every candidate as missing for a legitimately contained + * absolute logo while `resolvedTo` named the file. + */ + +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-logodiag-')), 'db.sqlite', +); +process.env.JWT_SECRET = process.env.JWT_SECRET || 'logodiag-test-secret'; + + +const request = require('supertest'); +const express = require('express'); +const bcrypt = require('bcrypt'); +const jwt = require('jsonwebtoken'); + +const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb'); + +describe('logo diagnostic disclosure (GHSA-29vm)', () => { + let db; let cleanup; let app; let token; + // bootCrmDb() sets STORAGE_PATH itself, so resolve these AFTER it runs. + let STORAGE; let logoDir; let logoPath; + + beforeAll(async () => { + ({ db, cleanup } = await bootCrmDb()); + await seedMinimal(db); + + // A legitimately contained absolute logo in a NON-standard storage subdir. + STORAGE = process.env.STORAGE_PATH; + logoDir = path.join(STORAGE, 'custom'); + logoPath = path.join(logoDir, 'logo.png'); + fs.mkdirSync(logoDir, { recursive: true }); + fs.writeFileSync(logoPath, 'png'); + + const setting = { setting_key: 'branding_logo_path', setting_value: JSON.stringify(logoPath), setting_type: 'branding' }; + const existing = await db('app_settings').where({ setting_key: 'branding_logo_path' }).first(); + if (existing) await db('app_settings').where({ setting_key: 'branding_logo_path' }).update(setting); + else await db('app_settings').insert(setting); + + const role = await db('roles').where({ name: 'super_admin' }).first(); + const r = await db('admin_users').insert({ + username: 'diag-admin', email: 'diag@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]; + token = jwt.sign( + { id, username: 'diag-admin', type: 'admin', role: 'super_admin', loginTime: Date.now() }, + process.env.JWT_SECRET, { expiresIn: '1h', issuer: 'picpeak-auth' }, + ); + + app = express(); + app.use(express.json()); + app.use('/api/admin/business-profile', require('../../src/routes/adminBusinessProfile')); + }, 120000); + + afterAll(async () => { if (cleanup) await cleanup(); }); + + it('does not leak absolute paths, cwd or storage root anywhere in the payload', async () => { + const res = await request(app) + .get('/api/admin/business-profile/logo-diagnostic') + .set('Authorization', `Bearer ${token}`); + + expect(res.status).toBe(200); + const body = JSON.stringify(res.body); + expect(body).not.toContain(STORAGE); + expect(body).not.toContain(process.cwd()); + expect(res.body.storageRoot).toBeUndefined(); + expect(res.body.cwd).toBeUndefined(); + }); + + it('still finds a contained absolute logo outside the standard subdirs', async () => { + const res = await request(app) + .get('/api/admin/business-profile/logo-diagnostic') + .set('Authorization', `Bearer ${token}`); + + const source = res.body.sources.find((s) => s.label === 'app_settings.branding_logo_path'); + expect(source).toBeTruthy(); + // The resolver keeps the contained absolute candidate, so the diagnostic + // must show it existing rather than reporting everything missing. + expect(source.candidates.some((c) => c.exists)).toBe(true); + expect(res.body.resolvedTo).toMatch(/^\//); + }); + + it('shows the / candidate for a ROOT-RELATIVE logo URL (round 3)', async () => { + // `/custom/logo.png` is a URL, not a disk path, but path.isAbsolute() says + // true for both. Gating the stripped joins on isAbsolute() therefore hid + // `/custom/logo.png` — a candidate resolveLogoFile does try and + // can resolve — so the diagnostic claimed nothing existed for a logo that + // renders fine, and collapsed the configured value to its basename. + await db('app_settings').where({ setting_key: 'branding_logo_path' }) + .update({ setting_value: JSON.stringify('/custom/logo.png') }); + + const res = await request(app) + .get('/api/admin/business-profile/logo-diagnostic') + .set('Authorization', `Bearer ${token}`); + + expect(res.status).toBe(200); + const source = res.body.sources.find((s) => s.label === 'app_settings.branding_logo_path'); + expect(source.candidates.some((c) => c.path === '/custom/logo.png' && c.exists)).toBe(true); + + // …and the disclosure guarantee still holds for this shape. + const body = JSON.stringify(res.body); + expect(body).not.toContain(STORAGE); + expect(body).not.toContain(process.cwd()); + + await db('app_settings').where({ setting_key: 'branding_logo_path' }) + .update({ setting_value: JSON.stringify(logoPath) }); + }); +}); diff --git a/backend/__tests__/services/publicSiteBrandTokens.test.js b/backend/__tests__/services/publicSiteBrandTokens.test.js new file mode 100644 index 00000000..a9c4c8f9 --- /dev/null +++ b/backend/__tests__/services/publicSiteBrandTokens.test.js @@ -0,0 +1,91 @@ +/** + * Brand-token substitution must not reintroduce markup after sanitization + * (GHSA-j347). + * + * buildCachedPayload sanitizes the operator's HTML and THEN calls + * applyBrandTokens on the result, which did a plain `String.replace` with no + * escaping. The default templates interpolate tokens into text and into quoted + * attributes (`{{company_name}} logo`, + * `href="mailto:{{support_email}}"`), so a token value could close the + * attribute and inject markup into the public origin. + * + * The writer is settings.edit (super_admin only) and the CSP blocks inline + * script, so this is defence-in-depth rather than a live RCE — but the + * sanitize-then-substitute ordering is a real bug either way. + */ + +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-brandtok-')), 'db.sqlite', +); +process.env.JWT_SECRET = process.env.JWT_SECRET || 'brandtok-test-secret'; + +const { _internal } = require('../../src/services/publicSiteService'); + +// applyBrandTokens / sanitizeBrandUrl are module-private; the service exports +// them under _internal for testing (see publicSiteService module.exports). +const { applyBrandTokens, sanitizeBrandUrl } = _internal || {}; + +const maybe = applyBrandTokens ? describe : describe.skip; + +maybe('applyBrandTokens escaping (GHSA-j347)', () => { + it('escapes markup in a text-position token', () => { + const out = applyBrandTokens('

{{company_name}}

', { + companyName: '', + }); + expect(out).not.toContain('