feat(#1518): add branding.homeUrl override for embedded deployments (#1576)

Red commit: 86083fe176 (CI run:
https://github.com/Kpa-clawbot/CoreScope/actions/runs/26970512724)

Fixes #1518.

Adds `branding.homeUrl` to the Branding tab so operators embedding
CoreScope inside a larger site can point the navbar logo at their own
home page instead of the in-app `#/` route.

## What

- New optional config: `branding.homeUrl`. When set, `<a
class="nav-brand">[href]` is rewritten to that URL. Empty / null /
invalid → falls through to the existing `#/` default.
- Customizer Branding tab gets a new "Home URL" field next to Logo URL.
- Strict whitelist validator `isValidHomeUrl()`:
- **Accepts**: `http(s)://...` absolute URLs, `#`-prefixed app routes
(`#/`, `#/home`, etc.)
- **Rejects**: `javascript:`, `data:`, `vbscript:`, `file:`, `about:`,
protocol-relative `//`, bare paths, ftp, whitespace, non-strings, and
whitespace-obfuscated `java\tscript:` payloads.
- Cross-origin URLs open in the SAME tab (no `target="_blank"`);
operators can wrap with their own anchor handling if they need new-tab.
- **Bottom-nav 🏠 unchanged** — stays in-app to preserve SPA back-stack
on mobile (per triage decision).

## Scope

Touched files:
- `public/customize-v2.js` — new field, validator, override application
- `config.example.json` — `branding.homeUrl` + `_comment` updated per
AGENTS.md Config Documentation Rule
- `test-issue-1518-home-url.js` — new unit suite (validator + DOM-string
asserts)
- `test-customize-branding-e2e.js` — extended with three homeUrl
assertions
- `.github/workflows/deploy.yml` — wires new unit test into CI

## TDD

- Red commit lands tests + a permissive `isValidHomeUrl` stub so the
assertions execute (no compile/undefined-function errors). Tests fail on
assertion as expected.
- Green commit replaces the stub with the real whitelist, adds the
Branding-tab field, wires the override, and updates
`config.example.json`.

## E2E coverage

Extended `test-customize-branding-e2e.js` with three browser-level
assertions:
- `homeUrl='https://example.com/embed-home'` → `.nav-brand[href]` equals
it
- `homeUrl='javascript:alert(1)'` → `.nav-brand[href]` is NOT
javascript: (validator drops it)
- Empty `homeUrl` → `.nav-brand[href]` falls through to `#/`

E2E assertion added: `test-customize-branding-e2e.js:~95`

## Out of scope

- `public/bottom-nav.js` 🏠 button — left alone deliberately (mobile SPA
back-stack).
- `target="_blank"` / `rel="noopener"` magic — operators who need
new-tab can wrap.
- Server-side validation — homeUrl is purely a frontend display
override; SITE_CONFIG already proxies `branding.*` opaquely
(`map[string]interface{}` in `cmd/server/config.go`), no shape change
required.
This commit is contained in:
Kpa-clawbot
2026-06-04 12:38:21 -07:00
committed by GitHub
parent 35b4bd8323
commit 9b36b7c487
5 changed files with 284 additions and 1 deletions
+1
View File
@@ -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
+2 -1
View File
@@ -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",
+62
View File
@@ -102,6 +102,22 @@
if (brandLink) brandLink.setAttribute('aria-label', alt + ' home');
}
// ── Brand home-link href swap (#1518) ──
// Apply a validated branding.homeUrl to <a class="nav-brand">[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 <a class="nav-brand">[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 @@
'<div class="cust-field"><label>Site Name' + _overrideDot('branding', 'siteName') + '</label><input type="text" data-cv2-field="branding.siteName" value="' + escAttr(b.siteName || '') + '"></div>' +
'<div class="cust-field"><label>Tagline' + _overrideDot('branding', 'tagline') + '</label><input type="text" data-cv2-field="branding.tagline" value="' + escAttr(b.tagline || '') + '"></div>' +
'<div class="cust-field"><label>Logo URL' + _overrideDot('branding', 'logoUrl') + '</label><input type="text" data-cv2-field="branding.logoUrl" value="' + escAttr(b.logoUrl || '') + '" placeholder="https://...">' + logoPreview + '</div>' +
'<div class="cust-field"><label>Home URL' + _overrideDot('branding', 'homeUrl') + '</label><input type="text" data-cv2-field="branding.homeUrl" value="' + escAttr(b.homeUrl || '') + '" placeholder="https://your-site.example/ or #/"></div>' +
'<div class="cust-field"><label>Favicon URL' + _overrideDot('branding', 'faviconUrl') + '</label><input type="text" data-cv2-field="branding.faviconUrl" value="' + escAttr(b.faviconUrl || '') + '" placeholder="https://..."></div>' +
'</div>';
}
@@ -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,
+45
View File
@@ -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 });
+174
View File
@@ -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 <a class="nav-brand">[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,<script>alert(1)</script>'],
['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);