[href], so it MUST
+ // reject any scheme that can execute script (javascript:, data:, vbscript:,
+ // etc.). Whitelist only:
+ // - http://... (absolute, plain http ā operators may need it for intranet)
+ // - https://... (absolute, https)
+ // - #... (app-relative hash route, e.g. "#/", "#/home")
+ // Empty / whitespace / non-string ā invalid (caller falls through to default).
+ //
+ // Defence-in-depth: also strip ALL whitespace before scheme-sniffing so a
+ // payload like "java\tscript:alert(1)" ā which some lenient URL parsers
+ // collapse to javascript: ā is rejected here too.
+ function isValidHomeUrl(val) {
+ if (typeof val !== 'string') return false;
+ var trimmed = val.replace(/^\s+/, '');
+ if (trimmed.length === 0) return false;
+ // Hash routes (app-internal) are always safe.
+ if (trimmed.charAt(0) === '#') return true;
+ // For scheme detection, collapse interior whitespace inside the scheme
+ // portion (chars before first ':'). HTML attribute parsing has been known
+ // to drop these, turning "java\tscript:" into "javascript:" at click time.
+ var colonIdx = trimmed.indexOf(':');
+ if (colonIdx === -1) return false;
+ var schemeRaw = trimmed.slice(0, colonIdx);
+ var schemeClean = schemeRaw.replace(/\s+/g, '').toLowerCase();
+ if (schemeClean !== 'http' && schemeClean !== 'https') return false;
+ // Must look like a real absolute URL: scheme://...
+ return /^https?:\/\//i.test(trimmed);
+ }
+
var TS_ENUMS = {
defaultMode: ['ago', 'absolute'],
timezone: ['local', 'utc'],
@@ -713,6 +759,10 @@
var fav = document.querySelector('link[rel="icon"]');
if (fav) fav.href = br.faviconUrl;
}
+ // #1518 ā apply validated homeUrl override to .nav-brand[href].
+ // Always call: invalid/empty restores '#/' so a cleared override visibly
+ // reverts. Validator rejects javascript:/data:/etc to prevent XSS.
+ _setBrandHomeUrl(br.homeUrl);
}
// Dispatch theme-changed event (bare, no payload ā matches existing behavior)
@@ -1203,6 +1253,7 @@
'' +
'' +
'' + logoPreview + '
' +
+ '' +
'' +
'';
}
@@ -2152,6 +2203,11 @@
var link = document.querySelector('link[rel="icon"]');
if (link && inp.value) link.href = inp.value;
}
+ if (section === 'branding' && key === 'homeUrl') {
+ // #1518 ā live nav-brand[href] swap. Validator silently drops
+ // bogus schemes so typing 'javascript:' never sets the attribute.
+ _setBrandHomeUrl(inp.value);
+ }
});
}
});
@@ -2494,6 +2550,11 @@
var link = document.querySelector('link[rel="icon"]');
if (link) link.href = overrides.branding.faviconUrl;
}
+ // #1518 ā re-apply homeUrl override after DOM is ready (handles cold
+ // page loads where the customizer pipeline ran before .nav-brand mounted).
+ if (overrides.branding.homeUrl) {
+ _setBrandHomeUrl(overrides.branding.homeUrl);
+ }
}
// Watch dark/light mode toggle and re-apply
@@ -2645,6 +2706,7 @@
validateShape: validateShape,
applyCSS: applyCSS,
isValidColor: isValidColor,
+ isValidHomeUrl: isValidHomeUrl,
isOverridden: _isOverridden,
// #1496 ā full reset (not just STORAGE_KEY). See _resetAll() above.
resetAll: _resetAll,
diff --git a/test-customize-branding-e2e.js b/test-customize-branding-e2e.js
index cbf94954..bd038780 100644
--- a/test-customize-branding-e2e.js
+++ b/test-customize-branding-e2e.js
@@ -90,6 +90,51 @@ function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); }
assert(src === testUrl, 'brand-logo src should match URL, got: ' + (src || '').slice(0, 40));
});
+ // āā #1518: branding.homeUrl override redirects nav-brand[href] āā
+ await step('#1518: branding.homeUrl override sets .nav-brand[href]', async () => {
+ const inp = await page.$('input[data-cv2-field="branding.homeUrl"]');
+ assert(inp, 'branding.homeUrl input missing ā Branding tab must expose homeUrl field');
+ const target = 'https://example.com/embed-home';
+ await page.evaluate((args) => {
+ args.el.value = args.v;
+ args.el.dispatchEvent(new Event('input', { bubbles: true }));
+ }, { el: inp, v: target });
+ await page.waitForTimeout(500);
+ const href = await page.evaluate(() => {
+ const a = document.querySelector('a.nav-brand');
+ return a ? a.getAttribute('href') : null;
+ });
+ assert(href === target, '.nav-brand[href] should equal homeUrl override, got: ' + href);
+ });
+
+ await step('#1518: branding.homeUrl rejects javascript: scheme', async () => {
+ const inp = await page.$('input[data-cv2-field="branding.homeUrl"]');
+ await page.evaluate((el) => {
+ el.value = 'javascript:alert(1)';
+ el.dispatchEvent(new Event('input', { bubbles: true }));
+ }, inp);
+ await page.waitForTimeout(500);
+ const href = await page.evaluate(() => {
+ const a = document.querySelector('a.nav-brand');
+ return a ? a.getAttribute('href') : null;
+ });
+ assert(href !== 'javascript:alert(1)', '.nav-brand[href] must NEVER be javascript:, got: ' + href);
+ });
+
+ await step('#1518: empty branding.homeUrl falls through to #/', async () => {
+ const inp = await page.$('input[data-cv2-field="branding.homeUrl"]');
+ await page.evaluate((el) => {
+ el.value = '';
+ el.dispatchEvent(new Event('input', { bubbles: true }));
+ }, inp);
+ await page.waitForTimeout(500);
+ const href = await page.evaluate(() => {
+ const a = document.querySelector('a.nav-brand');
+ return a ? a.getAttribute('href') : null;
+ });
+ assert(href === '#/', '.nav-brand[href] should fall through to "#/" when homeUrl is empty, got: ' + href);
+ });
+
await step('branding overrides persist across reload', async () => {
await page.reload({ waitUntil: 'load' });
await page.waitForFunction(() => window._customizerV2 && window._customizerV2.initDone, null, { timeout: 8000 });
diff --git a/test-issue-1518-home-url.js b/test-issue-1518-home-url.js
new file mode 100644
index 00000000..3516a687
--- /dev/null
+++ b/test-issue-1518-home-url.js
@@ -0,0 +1,174 @@
+/* Unit tests for issue #1518: branding.homeUrl validator + nav-brand override.
+ *
+ * The validator MUST reject any scheme that can execute script when rendered
+ * into