diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 66da6df1..681e06f4 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -96,6 +96,7 @@ jobs: node test-packet-filter.js node test-packet-filter-time.js node test-channels-merge-1498-unit.js + node test-issue-1518-home-url.js node test-channel-decrypt-insecure-context.js node test-live-region-filter.js node test-issue-1136-observer-iata-map.js diff --git a/config.example.json b/config.example.json index 915ddaef..371e0a9b 100644 --- a/config.example.json +++ b/config.example.json @@ -29,7 +29,8 @@ "tagline": "Real-time MeshCore LoRa mesh network analyzer", "logoUrl": null, "faviconUrl": null, - "_comment": "Customize site name, tagline, logo, and favicon. logoUrl/faviconUrl can be absolute URLs or relative paths." + "homeUrl": null, + "_comment": "Customize site name, tagline, logo, and favicon. logoUrl/faviconUrl can be absolute URLs or relative paths. homeUrl (#1518) overrides the navbar logo link target — set to an absolute http(s):// URL for operators embedding CoreScope inside a larger site, or a '#'-prefixed app route (e.g. '#/home') to keep it in-app. Validator rejects javascript:, data:, vbscript:, file:, about:, protocol-relative '//', and bare paths to block XSS. Cross-origin URLs open in the SAME tab (no target=_blank); wrap with your own anchor if you need new-tab behavior. The mobile bottom-nav šŸ  button is intentionally NOT overridden — it stays in-app to preserve SPA back-stack on phones." }, "theme": { "accent": "#4a9eff", diff --git a/public/customize-v2.js b/public/customize-v2.js index 1bf4655e..2f67dfa5 100644 --- a/public/customize-v2.js +++ b/public/customize-v2.js @@ -102,6 +102,22 @@ if (brandLink) brandLink.setAttribute('aria-label', alt + ' home'); } + // ── Brand home-link href swap (#1518) ── + // Apply a validated branding.homeUrl to [href]. When + // url is empty / invalid, restore the in-app default '#/' so operators can + // clear the override and immediately see the SPA route restored. + // Bottom-nav šŸ  stays in-app — do NOT touch public/bottom-nav.js here, that + // preserves the SPA back-stack on mobile (per #1518 triage). + function _setBrandHomeUrl(url) { + var brandLink = document.querySelector('a.nav-brand'); + if (!brandLink) return; + if (url && isValidHomeUrl(url)) { + brandLink.setAttribute('href', url); + } else { + brandLink.setAttribute('href', '#/'); + } + } + // ── Presets (copied from v1 customize.js) ── var PRESETS = { default: { @@ -347,6 +363,36 @@ return typeof val === 'number' && isFinite(val) && val >= 0 && val <= 1; } + // ── isValidHomeUrl (#1518) ── + // Branding.homeUrl is rendered into [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
[href] (javascript:, data:, vbscript:, file:, + * about:). It must accept http(s):// absolute URLs and app-relative hash + * routes (#/...). Empty / whitespace / non-string falls through to the default. + * + * The renderBranding output (DOM-string) must expose a data-cv2-field input + * for "branding.homeUrl" so the customizer Branding tab plumbing matches + * branding.logoUrl exactly. + */ +'use strict'; +const vm = require('vm'); +const fs = require('fs'); +const assert = require('assert'); + +let passed = 0, failed = 0; +function test(name, fn) { + try { fn(); passed++; console.log(' āœ… ' + name); } + catch (e) { failed++; console.log(' āŒ ' + name + ': ' + e.message); } +} + +function makeSandbox() { + const storage = {}; + const localStorage = { + _data: storage, + getItem(k) { return k in storage ? storage[k] : null; }, + setItem(k, v) { storage[k] = String(v); }, + removeItem(k) { delete storage[k]; }, + clear() { for (const k in storage) delete storage[k]; } + }; + const ctx = { + window: { + addEventListener: () => {}, + dispatchEvent: () => {}, + SITE_CONFIG: {}, + _SITE_CONFIG_ORIGINAL_HOME: null, + }, + document: { + readyState: 'loading', + createElement: () => ({ + id: '', textContent: '', innerHTML: '', className: '', + setAttribute: () => {}, appendChild: () => {}, + style: {}, addEventListener: () => {}, + querySelectorAll: () => [], querySelector: () => null, + }), + head: { appendChild: () => {} }, + getElementById: () => null, + addEventListener: () => {}, + querySelectorAll: () => [], + querySelector: () => null, + documentElement: { + style: { setProperty: () => {}, removeProperty: () => {}, getPropertyValue: () => '' }, + dataset: { theme: 'dark' }, + getAttribute: () => 'dark', + }, + }, + console, + localStorage, + setTimeout: (fn) => fn(), + clearTimeout: () => {}, + Date, Math, Array, Object, JSON, String, Number, Boolean, + parseInt, parseFloat, isNaN, Infinity, NaN, undefined, + MutationObserver: class { observe() {} }, + HashChangeEvent: class {}, + CustomEvent: class CustomEvent { constructor(type, opts) { this.type = type; this.detail = opts && opts.detail; } }, + getComputedStyle: () => ({ getPropertyValue: () => '' }), + }; + ctx.window.localStorage = localStorage; + ctx.self = ctx.window; + return ctx; +} + +function load() { + const ctx = makeSandbox(); + const code = fs.readFileSync('public/customize-v2.js', 'utf8'); + vm.createContext(ctx); + vm.runInContext(code, ctx, { filename: 'customize-v2.js' }); + return { ctx, api: ctx.window._customizerV2 }; +} + +console.log('\nšŸ“‹ Issue #1518 — branding.homeUrl validator + Branding tab field\n'); + +// ── isValidHomeUrl: rejects ── +const REJECT = [ + ['javascript: payload', 'javascript:alert(1)'], + ['JavaScript: mixed case', 'JaVaScRiPt:alert(1)'], + ['javascript with leading whitespace', ' javascript:alert(1)'], + ['data: URL', 'data:text/html,'], + ['vbscript:', 'vbscript:msgbox(1)'], + ['file:', 'file:///etc/passwd'], + ['about:blank', 'about:blank'], + ['empty string', ''], + ['whitespace only', ' \t\n '], + ['null', null], + ['undefined', undefined], + ['number', 42], + ['object', { url: 'https://x' }], + ['protocol-relative //evil.com', '//evil.com'], + ['no-scheme bare path /foo', '/foo'], + ['ftp://', 'ftp://example.com/'], + ['javascript hidden by tab', 'java\tscript:alert(1)'], +]; +console.log('isValidHomeUrl rejects:'); +REJECT.forEach(([name, input]) => { + test(name, () => { + const { api } = load(); + assert.strictEqual(api.isValidHomeUrl(input), false, + 'expected isValidHomeUrl(' + JSON.stringify(input) + ') === false'); + }); +}); + +// ── isValidHomeUrl: accepts ── +const ACCEPT = [ + ['https URL', 'https://example.com'], + ['https with path', 'https://example.com/path?q=1#frag'], + ['http URL', 'http://intranet.local/'], + ['HTTPS mixed case', 'HTTPS://Example.COM/'], + ['hash root', '#/'], + ['hash route', '#/home'], + ['hash deeper', '#/map?packet=abc'], + ['just #', '#'], +]; +console.log('isValidHomeUrl accepts:'); +ACCEPT.forEach(([name, input]) => { + test(name, () => { + const { api } = load(); + assert.strictEqual(api.isValidHomeUrl(input), true, + 'expected isValidHomeUrl(' + JSON.stringify(input) + ') === true'); + }); +}); + +// ── Branding tab exposes homeUrl field ── +console.log('Branding tab DOM:'); +test('renderBranding() output contains data-cv2-field="branding.homeUrl"', () => { + // The Branding tab markup is built by _renderBranding (private). We + // assert against the source text directly — same pattern used to verify + // logoUrl is wired. + const src = fs.readFileSync('public/customize-v2.js', 'utf8'); + assert.ok( + /data-cv2-field="branding\.homeUrl"/.test(src), + 'public/customize-v2.js must wire a data-cv2-field="branding.homeUrl" input in the Branding tab (mirror branding.logoUrl)' + ); +}); + +test('config.example.json declares branding.homeUrl + _comment_', () => { + const raw = fs.readFileSync('config.example.json', 'utf8'); + const cfg = JSON.parse(raw); + assert.ok(cfg.branding, 'config.example.json should have a branding section'); + assert.ok('homeUrl' in cfg.branding, + 'config.example.json branding.homeUrl key must be present (set to null by default)'); + // Per AGENTS.md Config Documentation Rule, a comment must describe behavior. + // Branding shares a single _comment field; it must mention homeUrl. + assert.ok( + cfg.branding._comment && /homeUrl/i.test(cfg.branding._comment), + 'config.example.json branding._comment must document homeUrl behavior' + ); +}); + +test('nav-brand[href] override is wired in applyCSS branding block', () => { + // Lines ~700-715 in customize-v2.js apply branding.* to the DOM. The fix + // adds a branch that sets .nav-brand[href] from a validated branding.homeUrl. + const src = fs.readFileSync('public/customize-v2.js', 'utf8'); + assert.ok( + /\.nav-brand[\s\S]{0,200}?setAttribute\(\s*['"]href['"]/.test(src) || + /querySelector\(\s*['"]\.nav-brand['"]\)[\s\S]{0,400}?\.href\s*=/.test(src) || + /\.nav-brand[\s\S]{0,400}?branding\.homeUrl/.test(src), + 'public/customize-v2.js must set the .nav-brand[href] attribute when branding.homeUrl is a valid override' + ); +}); + +// ── Summary ── +console.log('\n' + (passed + failed) + ' tests: ' + passed + ' passed, ' + failed + ' failed\n'); +process.exit(failed > 0 ? 1 : 0);