Skip to content

fix(ui): validate URL schemes for Home footer links (#429) - #432

Merged
dborup merged 2 commits into
masterfrom
codex/issue-429-footer-link-schemes
Oct 13, 2026
Merged

dborup merged 2 commits into
masterfrom
codex/issue-429-footer-link-schemes

Conversation

@dborup-agent

Copy link
Copy Markdown
Collaborator

Relates to #429

Problem

#420 (#368) gave miniMarkdown() link destinations a scheme allowlist. The same Home customization source had another href sink with no scheme check: footerLinks[].url in public/home.js, rendered as href="${escapeAttr(l.url)}". On master:

  • javascript:, data:text/html, vbscript: and their case- and whitespace-obfuscated forms became live footer links.
  • escapeAttr covers only " and ', so & stays raw. javascript:…, javascript:… and javascript:… carry no scheme as text, but the browser decodes the entity while it parses the attribute. The anchor resolves to javascript:.

The grep the issue asked for found more customization-fed URL sinks in public/customize-v2.js (branding logo and favicon). One of them executes:

  • branding.logoUrl was written unescaped into '<img src="' + logoUrl + '" …>' via innerHTML on any .brand-icon element. A logo URL of x" onerror="… runs the handler (verified in the browser test: injected handler fired 1 time(s) on master). The rebrand removed .brand-icon from index.html, so a stock page has no such element. The three writes were dead code, but they were still a live sink for any page that has one.
  • branding.logoUrl → navbar <img src>, the customizer preview <img src> and branding.faviconUrl → <link rel=icon>.href had no scheme check. These sinks are not executable in a browser, but the URL is importable content.

Source-code finding only. No live-site change was made.

Plan / what changed

public/roles.js: one check, three entry points, no second allowlist.

  • isSafeLinkUrl(url) is the predicate fix(ui): escape and validate miniMarkdown link destinations (#368) #420 wrote inside safeLinkHref, extracted unchanged: the same SAFE_LINK_SCHEMES (http, https), the same case and ASCII control/whitespace normalisation, and schemeless URLs still pass. miniMarkdown now calls it through safeLinkHref, so its behaviour is identical (the security(ui): escape and validate miniMarkdown link destinations #368 suite stays 53/0).
  • window.safeUrlAttr(url) is for a raw URL headed for a quoted attribute. It HTML-escapes & < > and then delegates to safeLinkHref. The result is fully escaped (& < > " '), or '' when rejected. Escaping & is what defeats the entity-obfuscated schemes.
  • window.isSafeImageUrl(url) is the image-sink form: isSafeLinkUrl plus data:image/… after the same normalisation. An <img> or icon never runs script from its URL, and the customizer has always accepted an inline logo (test-customize-branding-e2e.js pins exactly that). data:text/html and every other scheme stay rejected.

public/home.js: footer links are built by footerLinkHtml(l) through safeUrlAttr(l.url). A rejected URL renders as its label, which _renderHomeLabel already escapes.

public/customize-v2.js

  • _brandUrlOk(url) delegates to window.isSafeImageUrl and fails closed if roles.js is missing.
  • _setBrandLogoUrl treats a rejected URL as cleared.
  • A new _setFaviconUrl replaces the three copy-pasted link.href = … writes.
  • The logo preview only renders for an accepted URL.
  • These cover every path the branding URLs arrive by: the server theme, stored overrides (which is also what a theme import writes), and live customizer input.
  • The three dead .brand-icon innerHTML writes are removed rather than patched.

Sink inventory (href= / src= fed by customization or config)

Sink Source Action
public/home.js footer <a href> SITE_CONFIG.home.footerLinks[].url fixed — safeUrlAttr
public/roles.js miniMarkdown <a href> Home steps / FAQ already fixed by #420, now via the shared predicate
public/customize-v2.js _setBrandLogoUrl → navbar <img src> branding.logoUrl (theme, overrides, input) fixed — isSafeImageUrl
public/customize-v2.js .brand-icon innerHTML <img src> ×3 branding.logoUrl removed (dead, unescaped)
public/customize-v2.js favicon link.href ×3 branding.faviconUrl fixed — _setFaviconUrl
public/customize-v2.js logo preview <img src> branding.logoUrl fixed — gated
public/customize-v2.js _setBrandHomeUrl → .nav-brand[href] branding.homeUrl already validated by isValidHomeUrl (Kpa-clawbot#1518, stricter: absolute http(s) or #… only); unchanged
public/customize-v2.js footer/branding <input value> customization attribute value, not a URL sink; already escAttr
Leaflet tile URL templates (map.js, live.js, roles.js, map-tile-providers.js) operator server config only (the customizer only picks a registry provider id) not changed — non-navigational tile <img> fetches, not importable
window.EXTERNAL_URLS server config externalUrls not changed — no consumer renders it
public/customize.js (v1) — not changed — not loaded by index.html

Acceptance → tests → mutants

Unit: test-issue-429-config-url-schemes.js (141 assertions, registered in test-all.sh). It drives the real roles.js helpers in a vm sandbox:

  • B1 isSafeLinkUrl
  • B2 safeUrlAttr
  • B3 miniMarkdown and safeUrlAttr agree on every URL, and the allowlist is declared once
  • B6 isSafeImageUrl

It also pins the sinks at source level:

  • B4 home.js footer
  • B5 customize-v2.js setters, preview, and no innerHTML logo sink

Browser: test-xss-429-home-footer-e2e.js (19 checks, registered in the Playwright step of deploy.yml after the #368 test). It runs against a local Go server with e2e-fixture.db. Hostile customization is injected by rewriting /api/config/theme with page.route and by seeding cs-theme-overrides. The fixture DB is untouched and no payload leaves the machine. Every footer anchor is hovered and focused. The checks are DOM-level: handler and dialog counters, the protocol the browser computed for each anchor/img/link, and each anchor's attribute names.

# Requirement Test Red before Green after
R1 Footer URL gets the #420 scheme allowlist + normalisation; rejected → escaped label text unit B1, B2, B4 + E2E "no footer anchor resolves to an unsafe scheme", "every unsafe-scheme URL degraded to plain label text", "a rejected entry keeps its label as escaped text" yes yes
R2 Obfuscated schemes (case, ASCII controls/whitespace, HTML entities) cannot produce an executable link unit B1/B2 (incl. 4 entity cases) + E2E "entity-obfuscated schemes stay literal, inert relative URLs" yes yes
R3 Reuse the #420 helper, no second allowlist unit B3 (agreement matrix over every hostile + safe URL; SAFE_LINK_SCHEMES declared once), B4 "home.js grows no scheme allowlist", B5 _brandUrlOk delegates yes yes
R4 Other customization-fed href/src sinks get the same check unit B5, B6 + E2E: server theme ×3, stored overrides, customizer preview, customizer input, legacy .brand-icon yes yes
R5 Safe links and branding keep working unit "keeps …"/"accepts …" + E2E safe footer links (href + attrs), hash link navigates, safe logo + favicon applied, inline data:image/ logo applied, safe preview shown; test-customize-branding-e2e.js 10/10 guard yes

Red-before: public/roles.js, public/home.js and public/customize-v2.js were restored from origin/master (8f40525f) with the new tests in place. Unit 3 passed / 138 failed. Browser 9 passed / 10 failed, including injected handler fired 1 time(s) from the .brand-icon sink and xss429-ent-0 resolves to javascript:.

With the fix: unit 141 / 0, browser 19 / 0.

Each mutant was applied to the fixed file from a scratch snapshot. Both suites were re-run against a restarted local server, then the file was restored and verified with cmp.

Mutant Unit (141) E2E (19)
N1 safeUrlAttr stops escaping & 6 red 2 red
N2 drop the control/whitespace strip 30 red 3 red
N3 accept every scheme 51 red 8 red
N4 footer back to escapeAttr(l.url) 1 red 3 red
N5 .brand-icon innerHTML sink restored 1 red 1 red (handler fired)
N6 logo setter skips the check 1 red 5 red
N7 favicon setter skips the check 1 red 5 red
N8 logo preview ungated 1 red 1 red
N9 image check accepts any data: URL 2 red 1 red

Verification run locally

  • sh test-all.sh — 242 files, 242 passed, 0 failed
  • node test-frontend-helpers.js — 713 passed, 0 failed
  • node test-preflight-xss-gate.js — all assertions passed
  • Go: go test ./... green in every module (cmd/server, cmd/ingestor, cmd/migrate, cmd/decrypt, all internal/*)
  • bash scripts/check-xss-sinks.sh --diff origin/master — clean (exit 0); --file clean on all three changed files
  • npx eslint@8 on the three changed files — 0 errors, the same 11 warnings as on master
  • Browser, against a locally built corescope-server with a copy of test-fixtures/e2e-fixture.db prepared as in CI (freshen, Packets page collapse button in the left column of the table opens the dialog. Kpa-clawbot/CoreScope#1486/"Group Data" message type missing from packet view window's "message type" filter Kpa-clawbot/CoreScope#1791 seed, corescope-migrate, seeds 2073/199/245):
    • test-xss-429-home-footer-e2e.js 19/0
    • test-xss-368-home-links-e2e.js 9/0
    • test-customize-branding-e2e.js 10/10
    • test-customize-theme-e2e.js 8/8
    • test-customize-export-e2e.js 6/6
    • test-home-coverage-e2e.js 12/0
    • test-xss-333-pages-e2e.js 11/0
    • test-logo-rebrand-e2e.js, test-logo-default-sage-teal-e2e.js and test-issue-304-my-mesh-e2e.js pass
    • test-e2e-playwright.js 132/135 (3 skipped by the suite itself), exit 0

Constraints held:

  • cmd/server untouched (no files under cmd/).
  • No new map[string]interface{}.
  • No colours added.
  • Fork guards unchanged: 9 in deploy.yml, 1 in release-fast-path.yml.

Left for the owner

  1. mailto: footer links are now rejected. Footer links follow the security(ui): escape and validate miniMarkdown link destinations #368 allowlist, which leaves mailto: out. No default or example config uses one; the defaults are #/… routes. If site owners are expected to put a contact address in the footer, add mailto to SAFE_LINK_SCHEMES. That changes Markdown links too, and B1/B2 pin the exclusion.
  2. data:image/ is allowed on the image sinks only (logo, favicon, preview). That keeps the existing inline-logo behaviour. Narrowing it to http(s) would break test-customize-branding-e2e.js and any stored inline logo.
  3. isValidHomeUrl (Feature Request: Allow custom "home" url Kpa-clawbot/CoreScope#1518) stays a separate, stricter check for branding.homeUrl. It rejects relative paths on purpose. Folding it onto isSafeLinkUrl plus an "absolute or #" rule would be a small DRY follow-up, but it changes no security property, so it is not done here.
  4. public/customize.js (v1) still has the old unescaped .brand-icon and favicon writes. It is not loaded by index.html, so they are unreachable. Deleting or fixing the file is a separate cleanup.
  5. Protocol-relative //host footer URLs are accepted, as for Markdown links in fix(ui): escape and validate miniMarkdown link destinations (#368) #420. They carry no scheme and open cross-origin with target="_blank" rel="noopener".
  6. scripts/collect-frontend-coverage.js was not extended for the new branches. The instrumented coverage run was not executed locally.

🤖 Generated with Claude Code

dborup and others added 2 commits October 13, 2026 11:40
… sinks (#429)

Unit test drives the real roles.js helpers in a vm sandbox and pins, at
source level, that home.js footer links and the customize-v2.js branding
logo/favicon setters use the shared check. The browser test injects hostile
customization via /api/config/theme and cs-theme-overrides against a local
server and asserts on the protocols and attributes the browser computed.

Both are red on this commit: no window.isSafeLinkUrl/safeUrlAttr exist yet,
footer links and the logo/favicon accept javascript:/data:/vbscript:, an
entity-encoded &#106;avascript: footer URL resolves to javascript:, and a
legacy .brand-icon receives an unescaped <img src> that runs a handler.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…#429)

roles.js: the scheme check #420 added for miniMarkdown is now one exported
predicate, window.isSafeLinkUrl (same allowlist, same case and ASCII
control/whitespace normalisation). window.safeUrlAttr wraps it for a raw URL
headed for an HTML attribute and also escapes '&', so an entity-encoded
scheme stays literal text. window.isSafeImageUrl is the image-sink form: the
same check plus inline data:image/ URLs, which an <img> or icon cannot run.

home.js: footerLinks[].url goes through safeUrlAttr; a rejected URL renders
as its escaped label text.

customize-v2.js: the branding logo and favicon setters (server theme, stored
overrides/theme import, customizer input) and the logo preview use the
image-sink check; a rejected logo counts as cleared. The three dead
.brand-icon innerHTML writes, which interpolated logoUrl unescaped into
<img src="...">, are removed: the rebrand dropped that element from
index.html.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@dborup-agent

Copy link
Copy Markdown
Collaborator Author

Rapport — CS-pve-agent1 PR#432 #429 — head 4ca35bd

Status: Done. Every requirement is implemented, and each has a test that is red before and green after, plus at least one killed mutant. Every CI job on this head is green with no re-run, and the PR is left as a draft.

Evidence tags: [T] = I ran it. [A] = from reading the code. [K] = from CI.

Base: origin/master 8f40525f (merge of #420), unchanged at push time [T]. Two commits:

  • b2f22999: the tests and their registration, red on their own.
  • 4ca35bd0: the fix.

Author and committer are dborup <kontakt@meshview.dk> [T].

Requirements

# Requirement Change Test Mutant
R1 Scheme check for Home footerLinks[].url; a rejected link renders as plain escaped text home.js footerLinkHtml(l) → window.safeUrlAttr(l.url); rejected → _renderHomeLabel(l.label) (escaped) [A] unit B2, B4 + E2E "no footer anchor resolves to an unsafe scheme", "every unsafe-scheme URL degraded to plain label text", "a rejected entry keeps its label as escaped text, not markup" [T][K] N4 footer back to escapeAttr(l.url): unit 1 red, E2E 3 red [T]
R2 Hostile schemes incl. obfuscated ones are rejected; safe links work Same normalisation as #420 (case, U+0000 to U+0020, U+007F). safeUrlAttr also escapes &, so &#106;avascript:, &#x6A;avascript: and javascript&colon; stay literal relative URLs instead of decoding to javascript: [A] unit B1 (17 hostile, 4 empty, 9 safe), B2 (incl. 4 entity and 3 quote/tag-breakout cases) + E2E with 8 unsafe, 3 entity, 2 breakout and 4 safe footer URLs, hover/focus on every anchor, and a check that a safe hash link navigates [T][K] N1 drop the & escape: unit 6, E2E 2 red. N2 drop the control/whitespace strip: unit 30, E2E 3 red. N3 accept every scheme: unit 51, E2E 8 red [T]
R3 Reuse the #420 helper; no second helper The predicate inside #420's safeLinkHref is extracted unchanged as isSafeLinkUrl and exported. safeUrlAttr and isSafeImageUrl are thin wrappers over it. SAFE_LINK_SCHEMES is declared once, and miniMarkdown still goes through it [A] unit B3: miniMarkdown and isSafeLinkUrl agree on every hostile and safe URL, and SAFE_LINK_SCHEMES is declared once. B4: home.js has no allowlist of its own. B5: _brandUrlOk delegates. test-issue-368-minimarkdown-links.js is unchanged at 53/0 [T][K] N3 also flips the B3 agreement checks [T]
R4 Grep other customization/config-fed href=/src= sinks and apply the same check customize-v2.js changes. The logo setter, the new _setFaviconUrl (replaces 3 copy-pasted writes) and the logo preview now go through _brandUrlOk → isSafeImageUrl. That covers the server theme, stored overrides / theme import and customizer input. 3 dead .brand-icon innerHTML writes that put logoUrl unescaped into <img src="…"> are removed [A]. Full inventory in the PR body unit B5, B6 + E2E "server theme" ×3, "stored overrides", "customizer: hostile stored logo gets no preview", "customizer: typing a javascript: logo URL", "a legacy .brand-icon never receives logo markup" [T][K] N5 restore a .brand-icon sink: E2E 1 red (handler fired). N6 logo setter skips the check: E2E 5 red. N7 favicon setter skips it: E2E 5 red. N8 preview ungated: E2E 1 red. N9 image check accepts any data:: unit 2, E2E 1 red. Each is also unit-red [T]
R5 Browser E2E on Home against a LOCAL server test-xss-429-home-footer-e2e.js, registered in the Playwright step of deploy.yml local Go server on a private port, with a copy of e2e-fixture.db prepared as in CI (freshen, Kpa-clawbot#1486/Kpa-clawbot#1791 seed, migrate, seeds 2073/199/245). Stopped by pid/port afterwards [T] —
R6 No hardcoded colours; check-xss-sinks.sh --diff origin/master clean No colour literals added. The diff avoids HTML-string sinks on customization data --diff origin/master exit 0 and --file clean on all 3 files [T]. CI step "Preflight XSS gate — actual --diff check (PR only)" green [K] —

Red before (public/roles.js, home.js and customize-v2.js restored from origin/master with the new tests in place):

  • unit 3 passed / 138 failed;
  • browser 9 passed / 10 failed, including a live injected handler fired 1 time(s) from the .brand-icon sink and xss429-ent-0 resolves to javascript: [T].

Green after: unit 141 / 0, browser 19 / 0 [T][K].

Mutants were applied to the fixed files from a scratch snapshot. Both suites were re-run against a restarted local server, then each file was restored and verified with cmp. All 9 were killed by both suites [T].

CI per job (head 4ca35bd0, run 38392091612)

Job Result
Go Build & Test pass (17m53s). test-all.sh 242 passed / 0 failed over 242 files, incl. test-issue-429-config-url-schemes.js at 141/0. Preflight XSS --diff gate green [K]
Playwright E2E Tests pass (28m13s). test-xss-429-home-footer-e2e.js reported 19 passed / 0 failed [K]
Validate Docker Image pass [K]
Build & Publish Docker Image pass [K]
Release Artifacts / Publish Badges & Summary / Deploy Staging skipping (not applicable) [K]

No job needed a re-run, and the known flaky test #271 did not appear [K].

Local verification

  • sh test-all.sh: 242 / 242 [T]
  • node test-frontend-helpers.js: 713 / 0 [T]
  • node test-preflight-xss-gate.js: all assertions passed [T]
  • Go: go test ./... green in every module (cmd/server, cmd/ingestor, cmd/migrate, cmd/decrypt, all internal/*) [T]
  • npx eslint@8 on the 3 changed files: 0 errors, and the same 11 warnings as master [T]
  • E2E against the local server [T]:

Constraints held [T]:

  • cmd/server untouched (0 files under cmd/).
  • No new map[string]interface{}.
  • No colours added.
  • Fork guards unchanged: 9 in deploy.yml and 1 in release-fast-path.yml.
  • No closing keywords in the title, body or commits.

Decision taken during the work

On the first full run, test-customize-branding-e2e.js failed. It sets an inline data:image/svg+xml logo and expects it to apply. A plain http(s) allowlist on the image sinks would break that existing, tested behaviour and gain no security: an <img> or icon never runs script from its URL. So the image sinks use isSafeImageUrl, which is the same check plus data:image/… after the same normalisation. data:text/html and every other scheme are still rejected. The extra case is pinned by unit B6 and the E2E checks "inline data:image/ logo is still applied" and server-theme data:text/html, and mutant N9 shows the tests catch a looser version [T].

Remainders — left for the owner

  1. mailto: footer links are now rejected, following the security(ui): escape and validate miniMarkdown link destinations #368 allowlist. Nothing in the defaults or config.example.json uses one. Adding mailto to SAFE_LINK_SCHEMES would be a product call, and it would also affect Markdown links [A].
  2. isValidHomeUrl (Feature Request: Allow custom "home" url Kpa-clawbot/CoreScope#1518) remains a separate, stricter validator for branding.homeUrl. It accepts only absolute http(s) or #…. It is safe, and was left unchanged. Composing it on isSafeLinkUrl would be a DRY follow-up [A].
  3. public/customize.js (v1) still contains the old unescaped .brand-icon and favicon writes. The file is not loaded by index.html, so they are unreachable. Removing it is a separate cleanup [A].
  4. Leaflet tile URL templates and EXTERNAL_URLS were checked and not changed. Tile URLs come only from operator server config (the customizer picks a registry id) and are fetched as tile images. EXTERNAL_URLS has no consumer that renders it [A].
  5. Protocol-relative //host footer URLs are accepted, the same as for Markdown links in fix(ui): escape and validate miniMarkdown link destinations (#368) #420 [A].
  6. Not verified: browsers other than Chromium. javascript: anchors were detected through the computed protocol, not by activating them. scripts/collect-frontend-coverage.js was not extended, and the instrumented coverage run was not executed locally.

This is a source-code finding, not an observed production exploit. No staging or production access was used, and no server API key. No merge, ready-for-review or issue closing was done.

@dborup-agent

Copy link
Copy Markdown
Collaborator Author

Review — CS-MacBook PR#432 — head 4ca35bd

Dom: APPROVE with nits — every acceptance criterion in #429 has a red-before/green-after test, the fix reuses the #420 helper (no second allowlist), all CI jobs are green, and I reproduced the red→green transition and killed 5 mutants (3 unit, 2 E2E) myself against a local server. The nits below are non-blocking; the PR is a draft, so the merge call stays with the owner.

Evidence tags: [T] I ran it · [A] read the code · [K] from CI.

Base: origin/master is 8f40525f (merge of #420). git merge-tree --write-tree origin/master <head> yields the head's own tree — a clean fast-forward, so the merged tree I tested == head [T]. git ls-remote on the head was identical before and after the review [T].

Findings

ID Sev Where Finding
— — — No correctness, security, or scope defects found.
N1 nit (non-blocking) customize-v2.js _setFaviconUrl A rejected favicon URL is silently ignored (no write) rather than reset to default, unlike _setBrandLogoUrl which clears to the default logo. Not a security issue (nothing hostile is applied) and in practice the favicon stays the index.html default; only a minor behavioural asymmetry between the two setters [A].
N2 nit (non-blocking) process PR is a draft. All green, but no ready/merge was done or should be done by a reviewer — left for the owner.

Acceptance criteria → verification (one by one, against issue #429)

  • AC1 — Apply the fix(ui): escape and validate miniMarkdown link destinations (#368) #420 scheme allowlist + normalisation to footerLinks[].url. home.js now builds footer anchors via footerLinkHtml(l) → window.safeUrlAttr(l.url); the raw escapeAttr(l.url) sink is gone [A]. Pinned by unit B4 and E2E "no footer anchor resolves to an unsafe scheme" (19/0) [T][K]. Met.
  • AC2 — Render a rejected link as plain escaped text. footerLinkHtml returns _renderHomeLabel(l.label) (escaped) when safeUrlAttr returns '' [A]. E2E "a rejected entry keeps its label as escaped text, not markup" asserts the hostile-label entry renders verbatim with 0 <img> in the footer [T]. Met.
  • AC3 — Reuse the fix(ui): escape and validate miniMarkdown link destinations (#368) #420 helper; do not create a second allowlist. The fix(ui): escape and validate miniMarkdown link destinations (#368) #420 predicate is extracted unchanged as isSafeLinkUrl; safeLinkHref (miniMarkdown), safeUrlAttr, and isSafeImageUrl all route through it; SAFE_LINK_SCHEMES is declared once [A]. Unit B3 proves miniMarkdown and isSafeLinkUrl agree on every hostile+safe URL and that the allowlist literal appears exactly once; security(ui): escape and validate miniMarkdown link destinations #368 suite stays 53/0 [T]. Met.
  • AC4 — Grep other customization/config-fed href=/src= sinks and apply the same check. Full inventory in the PR body; the live changes are the branding logo setter, favicon setter, and logo preview in customize-v2.js, all gated by _brandUrlOk → isSafeImageUrl; the three dead, unescaped .brand-icon innerHTML <img src> writes are removed [A]. _setBrandLogoUrl only ever uses setAttribute('src', …) — no innerHTML path remains [A]. Pinned by unit B5/B6 + E2E (server theme ×3, stored overrides, customizer input, preview, legacy .brand-icon) [T]. Met.
  • AC5 — Red/green tests incl. obfuscated hostile schemes + a safe case. Entity-obfuscation (&#106;avascript:, &#x6A;, javascript&colon;), case, ASCII control/whitespace, quote/tag breakout all covered at unit (B1/B2) and DOM (E2E) level; safe http/https/hash/relative links verified to still render with exactly href,class,target,rel [T]. The &-escape in safeUrlAttr is what defeats the entity forms: the escaped string carries no scheme for the check and the browser renders it as literal text (E2E asserts a.href equals the literal and protocol resolves to http/https) [T]. Met.

Red-before / green-after [T]

  • Restored public/roles.js, home.js, customize-v2.js from origin/master with the new tests in place → unit 3 passed / 138 failed (matches the PR). With the fix → unit 141 / 0.
  • E2E test-xss-429-home-footer-e2e.js → 19 / 0 against a local server.

Mutants I ran myself (not just the author's) [T]

Against the fixed tree; baseline unit 141/0, E2E 19/0.

My mutant Target Unit E2E
A isSafeLinkUrl → accept every scheme (return !!probe) 51 red —
B safeUrlAttr → stop escaping & 6 red —
C linkUrlProbe → drop the control/whitespace strip 30 red —
D home.js footer href → raw l.url (no check at all) — 5 red
E _setBrandLogoUrl → skip the _brandUrlOk gate — 5 red

All five were caught; after each the file was restored and cmp-verified against the pristine merged source. Unit kill counts (51/6/30) match the author's N3/N1/N2 exactly; E2E N6→5 matches; my D is a strictly stronger variant of the author's N4 and is still red.

Tests run (merged tree, local) [T]

CI per job (head 4ca35bd0, run 38392091612) [K]

Go Build & Test ✅ · Playwright E2E ✅ (reports #429 19/0) · Validate Docker ✅ · Build & Publish Docker ✅ · Release/Badges/Deploy skipped (not applicable). No re-runs; no known flaky test (#256 Hash-Stats-sort / #267 backfill) appeared.

Constraints [T]/[A]

  • cmd/server read-only — the diff touches no .go and no cmd/ file [A].
  • No new map[string]interface{} — no Go changes at all [A].
  • No hardcoded colours added — the only #… additions in the diff are &#106;-style HTML entities in test fixtures, not CSS colours [A].
  • Fork guards: 9 in deploy.yml, 1 in release-fast-path.yml [T].
  • No closing keywords in title, body, or commit messages ("Relates to security(ui): validate URL schemes for Home footer links (same source as #368) #429" only) [T].
  • Commit author & committer: dborup <kontakt@meshview.dk> on both commits [T].

"Left for the owner" — I agree these are correct deferrals, not silent scope creep

mailto: footer links now rejected (follows #368; adding it is a product call); data:image/ allowed on image sinks only (preserves existing tested inline-logo behaviour; <img>/favicon never execute SVG script); isValidHomeUrl (Kpa-clawbot#1518) left as the stricter separate validator; v1 public/customize.js dead sinks left (not loaded by index.html); protocol-relative //host footer URLs accepted (same as #420); frontend coverage instrumentation not extended. None require a product decision taken silently.

Not verified

  • Browsers other than Chromium (CI and I use Chromium only).
  • javascript: anchors were judged via the browser-computed protocol, not by activating them.
  • go test -race ./cmd/server/... passed locally (ok github.com/corescope/server, 370s, 0 failures / 0 races) [T] and CI "Go Build & Test" is green [K]; the PR changes no Go, so this is confirmatory only.
  • The instrumented-coverage build path (public-instrumented): I ran against a plain -public public build.

@dborup
dborup marked this pull request as ready for review October 13, 2026 12:48
@dborup
dborup merged commit 779f2b9 into master Oct 13, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants