164129b8f5
* 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
* 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
---------
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
92 lines
3.7 KiB
JavaScript
92 lines
3.7 KiB
JavaScript
/**
|
|
* 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');
|
|
});
|
|
});
|