fix(security): escape brand tokens, block tracker redirects, trim logo diagnostic (stable) (#967)
* fix(security): escape brand tokens, block tracker redirects, trim logo diagnostic (GHSA-j347, mw76, 29vm)
GHSA-j347 — buildCachedPayload sanitizes the operator's HTML and THEN runs
applyBrandTokens over the result with a plain String.replace, so any markup in
a token value reached the public origin unfiltered. The default templates
interpolate tokens into text AND into quoted attributes
(<img src="{{brand_logo_url}}" alt="{{company_name}} logo">,
href="mailto:{{support_email}}"), so a value could close the attribute and
inject. Token values are now HTML-escaped on substitution, mirroring
galleryOgService's escapeHtml. sanitizeBrandUrl's case-sensitive literal
'javascript:' check (which 'JavaScript:' walked straight past) is replaced by
an http/https scheme allowlist; relative logo paths are unaffected.
Writer is settings.edit (super_admin only) and the CSP blocks inline script,
so this is defence-in-depth — but sanitize-then-substitute is a real ordering
bug regardless.
GHSA-mw76 — the SSRF decline STANDS: self-hosted operators legitimately point
analytics at private addresses, so connection-time IP blocking would break real
deployments. Fixed only the narrow leak: undici strips
Authorization/Cookie/Proxy-Authorization/Host across a cross-origin redirect,
but umamiAdapter sends a CUSTOM x-umami-api-key header, which would be replayed
verbatim to the redirect target. Both adapters now use redirect: 'error'.
GHSA-29vm — the logo diagnostic echoed absolute storage roots, process.cwd()
and absolute candidate paths. It now reports candidates relative to
<STORAGE>/<CWD_STORAGE>, which answers the same 'which candidate existed'
question. It also still advertised the raw-absolute candidate that GHSA-c7x5
removed from resolveLogoFile, so it was misreporting what the resolver tries —
aligned with the real candidate list.
publicSiteService.test.js expectation updated: an '&' in a company name is now
emitted as '&'. Renders identically; the raw payload string differs.
* fix(security): codex round 2 — stop the remaining logo-path disclosure, mirror the resolver (GHSA-29vm)
- sources[].value was still echoed verbatim. branding_logo_path is stored
ABSOLUTE by multer, so relativising only resolvedTo and the candidate paths
left the filesystem layout going out anyway. It is now relativised too.
- Round 1 dropped the raw-absolute candidate on the grounds that GHSA-c7x5
removed it from resolveLogoFile — but the c7x5 follow-up RE-ADDED it (kept,
subject to the containment filter, so a legitimate multer path still
resolves). The diagnostic therefore reported every candidate as missing for
a contained absolute logo while resolvedTo named the file. It now mirrors the
resolver, containment filter included.
One deliberate cosmetic divergence, commented in place: for an absolute value
the resolver also tries path.join(root, value-minus-leading-slash), which can
never exist and would re-embed the absolute path this endpoint must stop
echoing. Omitted; every candidate that can actually match is still shown.
* fix(security): codex round 3 — mirror the resolver for root-relative logo paths (GHSA-29vm)
The logo diagnostic skipped the `<STORAGE>/<value>` candidates whenever
path.isAbsolute(value) was true. That test cannot distinguish a multer disk
path from a root-relative URL such as `/custom/logo.png`, and for the URL form
resolveLogoFile.generateCandidates() does try `<STORAGE>/custom/logo.png` and
can resolve it — so the endpoint reported "no source candidate exists" about a
logo that renders fine, and collapsed the configured value to its basename.
The stripped joins are now built unconditionally, exactly as the resolver does.
Disclosure stays closed: every candidate still passes the containment filter and
redact() rewrites survivors to `<STORAGE>/…`, never an absolute host path.
Claude-Session: https://claude.ai/code/session_01F211U4dDbEj4zXiyKbi9me
(cherry picked from commit 093480a753ff3d4b6ed48dd9f1108f975c8e0d47)
* fix(security): gate the logo stripped-joins on containment, not isAbsolute (GHSA-29vm)
The previous commit dropped the isAbsolute() gate entirely and regressed
logoDiagnostic's own disclosure assertion: for a genuine multer disk path,
path.join(root, value-minus-leading-slash) yields
`<STORAGE>/tmp/…/storage/custom/logo.png`, and redact() only rewrites the
LEADING root — so the inner absolute path went straight back into the payload.
The right discriminator is not "is this absolute" (which cannot separate a disk
path from a root-relative URL) but "does the value already resolve inside a
storage root". If it does, it is a real disk path, the raw candidate already
covers it, and the stripped join is the double-prefixed junk that can never
exist. If it does not — the `/custom/logo.png` URL form — the stripped join is
exactly what resolveLogoFile resolves, and is shown.
Covered by a new case asserting both halves: the candidate appears for the URL
form, and the payload still contains neither the storage root nor cwd.
Claude-Session: https://claude.ai/code/session_01F211U4dDbEj4zXiyKbi9me
(cherry picked from commit c6b95d3cd1cb28e5c2828d29d4d63fadad981dcf)
---------
Co-authored-by: Paul Nothaft <[email protected]>
This commit is contained in:
co-authored by
Paul Nothaft
parent
4e99897313
commit
7f27e6771f
@@ -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: '[email protected]',
|
||||||
|
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(/^<STORAGE>\//);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the <STORAGE>/<value> 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
|
||||||
|
// `<STORAGE>/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 === '<STORAGE>/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) });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 (`<img src="{{brand_logo_url}}" alt="{{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('<p>{{company_name}}</p>', {
|
||||||
|
companyName: '<script>alert(1)</script>',
|
||||||
|
});
|
||||||
|
expect(out).not.toContain('<script>');
|
||||||
|
expect(out).toContain('<script>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('escapes a quote that would break out of an attribute', () => {
|
||||||
|
const out = applyBrandTokens(
|
||||||
|
'<img src="/x.png" alt="{{company_name}} logo">',
|
||||||
|
{ companyName: '" onerror="alert(1)' },
|
||||||
|
);
|
||||||
|
// The injected quotes must be entity-encoded, so the payload stays INSIDE
|
||||||
|
// the alt value as text instead of terminating it and forming a real
|
||||||
|
// onerror attribute. (`onerror=` still appears as literal characters —
|
||||||
|
// that is inert; what matters is that no raw `"` closed the attribute.)
|
||||||
|
expect(out).not.toContain('" onerror="');
|
||||||
|
expect(out).toContain('" onerror="');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('escapes the logo url token used inside src="..."', () => {
|
||||||
|
const out = applyBrandTokens('<img src="{{brand_logo_url}}">', {
|
||||||
|
logoUrl: '" onerror="alert(1)',
|
||||||
|
});
|
||||||
|
expect(out).not.toContain('" onerror="');
|
||||||
|
expect(out).toContain('"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves ordinary values readable', () => {
|
||||||
|
const out = applyBrandTokens('<p>{{company_name}}</p>', { companyName: 'Acme Photos' });
|
||||||
|
expect(out).toContain('Acme Photos');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const maybeUrl = sanitizeBrandUrl ? describe : describe.skip;
|
||||||
|
|
||||||
|
maybeUrl('sanitizeBrandUrl scheme allowlist (GHSA-j347)', () => {
|
||||||
|
it('rejects javascript: regardless of case', () => {
|
||||||
|
expect(sanitizeBrandUrl('javascript:alert(1)')).toBeNull();
|
||||||
|
// The old check was a case-sensitive startsWith and missed these.
|
||||||
|
expect(sanitizeBrandUrl('JavaScript:alert(1)')).toBeNull();
|
||||||
|
expect(sanitizeBrandUrl(' JAVASCRIPT:alert(1)')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects other non-http schemes', () => {
|
||||||
|
expect(sanitizeBrandUrl('data:text/html;base64,PHN2Zz4=')).toBeNull();
|
||||||
|
expect(sanitizeBrandUrl('vbscript:msgbox(1)')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps http(s) and relative logo paths working', () => {
|
||||||
|
expect(sanitizeBrandUrl('https://cdn.example.com/logo.png'))
|
||||||
|
.toBe('https://cdn.example.com/logo.png');
|
||||||
|
expect(sanitizeBrandUrl('/uploads/logos/logo.png')).toBe('/uploads/logos/logo.png');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -52,7 +52,11 @@ describe('publicSiteService', () => {
|
|||||||
const payload = await getPublicSitePayload({ bypassCache: true });
|
const payload = await getPublicSitePayload({ bypassCache: true });
|
||||||
|
|
||||||
expect(payload.enabled).toBe(true);
|
expect(payload.enabled).toBe(true);
|
||||||
expect(payload.html).toContain('<h1>Willow & Pine Studio</h1>');
|
// Brand tokens are HTML-escaped on substitution now (GHSA-j347), so a bare
|
||||||
|
// `&` in the company name is emitted as the `&` entity. That renders
|
||||||
|
// identically in a browser — it is the correctly-encoded form — but the raw
|
||||||
|
// payload string differs from the pre-fix output.
|
||||||
|
expect(payload.html).toContain('<h1>Willow & Pine Studio</h1>');
|
||||||
expect(payload.html).not.toContain('<script');
|
expect(payload.html).not.toContain('<script');
|
||||||
expect(payload.baseCss.length).toBeGreaterThan(0);
|
expect(payload.baseCss.length).toBeGreaterThan(0);
|
||||||
expect(payload.branding.companyName).toBe('Willow & Pine Studio');
|
expect(payload.branding.companyName).toBe('Willow & Pine Studio');
|
||||||
|
|||||||
@@ -249,33 +249,84 @@ router.get(
|
|||||||
const brandingLogoUrl = await getAppSetting('branding_logo_url');
|
const brandingLogoUrl = await getAppSetting('branding_logo_url');
|
||||||
const resolved = await resolveLogoFile(profile);
|
const resolved = await resolveLogoFile(profile);
|
||||||
|
|
||||||
|
// GHSA-29vm: report candidates RELATIVE to the storage roots rather than
|
||||||
|
// echoing absolute container paths and process.cwd(). This endpoint exists
|
||||||
|
// to answer "which candidate did/didn't exist", which relative paths answer
|
||||||
|
// just as well without handing out the filesystem layout.
|
||||||
|
const cwdStorage = path.join(process.cwd(), 'storage');
|
||||||
|
const relativise = (p) => {
|
||||||
|
for (const [name, root] of [['STORAGE', storageRoot], ['CWD_STORAGE', cwdStorage]]) {
|
||||||
|
const rel = path.relative(root, p);
|
||||||
|
if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) {
|
||||||
|
return `<${name}>/${rel.split(path.sep).join('/')}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return path.basename(p);
|
||||||
|
};
|
||||||
|
|
||||||
const inspect = (label, raw) => {
|
const inspect = (label, raw) => {
|
||||||
const value = (raw || '').toString().trim();
|
const value = (raw || '').toString().trim();
|
||||||
if (!value) return { label, value: null, candidates: [] };
|
if (!value) return { label, value: null, candidates: [] };
|
||||||
const stripped = value.replace(/^\/+/, '');
|
const stripped = value.replace(/^\/+/, '');
|
||||||
const baseName = path.basename(value);
|
const baseName = path.basename(value);
|
||||||
|
// Mirrors resolveLogoFile's candidate list EXACTLY. It keeps the raw
|
||||||
|
// absolute value as a candidate (multer stores branding_logo_path
|
||||||
|
// absolute) and lets the storage-root containment filter reject it when
|
||||||
|
// it points outside — so the diagnostic must include it too, or a
|
||||||
|
// legitimately-contained absolute logo shows every candidate as missing
|
||||||
|
// while resolvedTo names the file.
|
||||||
|
// The stripped joins (`<ROOT>/<value-minus-leading-slash>`) are gated on
|
||||||
|
// containment, NOT on path.isAbsolute(). isAbsolute() cannot tell a
|
||||||
|
// multer disk path from a root-relative URL like `/custom/logo.png`, and
|
||||||
|
// for the URL form `<STORAGE>/custom/logo.png` is a file the resolver
|
||||||
|
// genuinely returns — skipping it made this endpoint report "no source
|
||||||
|
// candidate exists" about a logo that renders fine.
|
||||||
|
//
|
||||||
|
// The gate is instead: does the raw value ALREADY resolve inside a
|
||||||
|
// storage root? If so it is a real disk path, the raw candidate below
|
||||||
|
// covers it, and the stripped join would only produce a double-prefixed
|
||||||
|
// path that can never exist while re-embedding the absolute path
|
||||||
|
// GHSA-29vm exists to stop echoing (redact() strips only the leading
|
||||||
|
// root, so the inner one would survive).
|
||||||
|
const valueInsideRoot = path.isAbsolute(value) && [
|
||||||
|
path.resolve(storageRoot), path.resolve(cwdStorage),
|
||||||
|
].some((root) => {
|
||||||
|
const r = path.resolve(value);
|
||||||
|
return r === root || r.startsWith(root + path.sep);
|
||||||
|
});
|
||||||
|
const strippedJoins = valueInsideRoot
|
||||||
|
? []
|
||||||
|
: [path.join(storageRoot, stripped), path.join(cwdStorage, stripped)];
|
||||||
const candidates = [
|
const candidates = [
|
||||||
path.isAbsolute(value) ? value : null,
|
...(path.isAbsolute(value) ? [value] : []),
|
||||||
path.join(storageRoot, stripped),
|
...strippedJoins,
|
||||||
path.join(storageRoot, 'uploads', 'logos', baseName),
|
path.join(storageRoot, 'uploads', 'logos', baseName),
|
||||||
path.join(storageRoot, 'branding', baseName),
|
path.join(storageRoot, 'branding', baseName),
|
||||||
path.join(process.cwd(), 'storage', stripped),
|
path.join(cwdStorage, 'uploads', 'logos', baseName),
|
||||||
path.join(process.cwd(), 'storage', 'uploads', 'logos', baseName),
|
path.join(cwdStorage, 'branding', baseName),
|
||||||
path.join(process.cwd(), 'storage', 'branding', baseName),
|
];
|
||||||
].filter(Boolean);
|
const roots = [path.resolve(storageRoot), path.resolve(cwdStorage)];
|
||||||
|
const contained = candidates.filter((c) => {
|
||||||
|
const r = path.resolve(c);
|
||||||
|
return roots.some((root) => r === root || r.startsWith(root + path.sep));
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
label, value,
|
label,
|
||||||
candidates: [...new Set(candidates)].map((p) => ({
|
// GHSA-29vm: branding_logo_path is stored absolute by multer, so
|
||||||
path: p,
|
// echoing it back handed out the filesystem layout just as the
|
||||||
|
// candidate paths did. Relativise it the same way.
|
||||||
|
value: path.isAbsolute(value) ? relativise(value) : value,
|
||||||
|
candidates: [...new Set(contained)].map((p) => ({
|
||||||
|
path: relativise(p),
|
||||||
exists: (() => { try { return fs.existsSync(p) && fs.statSync(p).isFile(); } catch { return false; } })(),
|
exists: (() => { try { return fs.existsSync(p) && fs.statSync(p).isFile(); } catch { return false; } })(),
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
return successResponse(res, {
|
return successResponse(res, {
|
||||||
storageRoot,
|
// Absolute storageRoot / cwd deliberately omitted (GHSA-29vm); the
|
||||||
cwd: process.cwd(),
|
// candidate paths below are shown relative to <STORAGE>/<CWD_STORAGE>.
|
||||||
resolvedTo: resolved,
|
resolvedTo: resolved ? relativise(resolved) : null,
|
||||||
sources: [
|
sources: [
|
||||||
inspect('business_profile.logo_path', profile?.logo_path),
|
inspect('business_profile.logo_path', profile?.logo_path),
|
||||||
inspect('app_settings.branding_logo_path', brandingDiskPath),
|
inspect('app_settings.branding_logo_path', brandingDiskPath),
|
||||||
|
|||||||
@@ -63,13 +63,40 @@ function sanitizeBrandUrl(url) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const trimmed = url.trim();
|
const trimmed = url.trim();
|
||||||
if (trimmed.startsWith('javascript:')) {
|
// GHSA-j347: the old check was a case-sensitive literal `javascript:`, which
|
||||||
|
// `JavaScript:` walks straight past. Allowlist the schemes a logo URL can
|
||||||
|
// legitimately use instead of blocklisting one spelling. Relative paths (the
|
||||||
|
// common case — /uploads/logos/x.png) carry no scheme and are unaffected.
|
||||||
|
const scheme = trimmed.match(/^\s*([a-z][a-z0-9+.-]*)\s*:/i);
|
||||||
|
if (scheme && !['http', 'https'].includes(scheme[1].toLowerCase())) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return trimmed;
|
return trimmed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTML-escape a brand token value (GHSA-j347).
|
||||||
|
*
|
||||||
|
* Brand tokens are substituted AFTER sanitize-html runs, so markup in a token
|
||||||
|
* value reaches the public page unfiltered. The default templates interpolate
|
||||||
|
* tokens into text AND into quoted attributes
|
||||||
|
* (`<img src="{{brand_logo_url}}" alt="{{company_name}} logo">`,
|
||||||
|
* `href="mailto:{{support_email}}"`), so escaping the five HTML-significant
|
||||||
|
* characters is correct in both positions.
|
||||||
|
*
|
||||||
|
* Mirrors galleryOgService's escapeHtml, which already handles this correctly.
|
||||||
|
*/
|
||||||
|
function escapeTokenValue(value) {
|
||||||
|
if (value === null || value === undefined) return '';
|
||||||
|
return String(value)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchBrandingContext() {
|
async function fetchBrandingContext() {
|
||||||
const rows = await db('app_settings')
|
const rows = await db('app_settings')
|
||||||
.whereIn('setting_key', [
|
.whereIn('setting_key', [
|
||||||
@@ -265,13 +292,18 @@ function applyBrandTokens(html, branding) {
|
|||||||
brand_text_hex: branding.colors?.text || '#0f172a'
|
brand_text_hex: branding.colors?.text || '#0f172a'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Escape on substitution (GHSA-j347) — this runs AFTER sanitizeHtmlPayload,
|
||||||
|
// so an unescaped value would reintroduce raw markup into the public origin.
|
||||||
return html.replace(/\{\{\s*(company_name|company_tagline|support_email|brand_logo_url|brand_primary_hex|brand_accent_hex|brand_background_hex|brand_text_hex)\s*\}\}/gi,
|
return html.replace(/\{\{\s*(company_name|company_tagline|support_email|brand_logo_url|brand_primary_hex|brand_accent_hex|brand_background_hex|brand_text_hex)\s*\}\}/gi,
|
||||||
(_, key) => tokens[key] || '');
|
(_, key) => escapeTokenValue(tokens[key] || ''));
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
getPublicSitePayload,
|
getPublicSitePayload,
|
||||||
clearPublicSiteCache,
|
clearPublicSiteCache,
|
||||||
getDefaultPublicSitePayload,
|
getDefaultPublicSitePayload,
|
||||||
getRawPublicSiteSettings
|
getRawPublicSiteSettings,
|
||||||
|
// Exposed for tests only — the token-escaping and URL-scheme rules
|
||||||
|
// (GHSA-j347) are worth pinning directly rather than through the cache.
|
||||||
|
_internal: { applyBrandTokens, sanitizeBrandUrl }
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -59,6 +59,13 @@ function buildAdapter({ baseUrl, websiteId, apiKey }) {
|
|||||||
Authorization: `Bearer ${apiKey}`,
|
Authorization: `Bearer ${apiKey}`,
|
||||||
Accept: 'application/json',
|
Accept: 'application/json',
|
||||||
},
|
},
|
||||||
|
// Never follow a redirect (GHSA-mw76). undici only strips
|
||||||
|
// Authorization/Cookie/Proxy-Authorization/Host when a redirect
|
||||||
|
// crosses origins — a custom key header would be replayed verbatim to
|
||||||
|
// whatever host the tracker redirects to. Self-hosted trackers on
|
||||||
|
// private addresses keep working; only a proxy that 301s is affected,
|
||||||
|
// and that surfaces as a clear logged error rather than a silent leak.
|
||||||
|
redirect: 'error',
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -41,6 +41,13 @@ function buildAdapter({ baseUrl, websiteId, apiKey }) {
|
|||||||
'x-umami-api-key': apiKey,
|
'x-umami-api-key': apiKey,
|
||||||
Accept: 'application/json',
|
Accept: 'application/json',
|
||||||
},
|
},
|
||||||
|
// Never follow a redirect (GHSA-mw76). undici only strips
|
||||||
|
// Authorization/Cookie/Proxy-Authorization/Host when a redirect
|
||||||
|
// crosses origins — a custom key header would be replayed verbatim to
|
||||||
|
// whatever host the tracker redirects to. Self-hosted trackers on
|
||||||
|
// private addresses keep working; only a proxy that 301s is affected,
|
||||||
|
// and that surfaces as a clear logged error rather than a silent leak.
|
||||||
|
redirect: 'error',
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
Reference in New Issue
Block a user