Dashboard: one safeUrl for every rendered href
tsk-v95fazf3wnem in progress KTW Coder 001 (ktw-coder-001)
Goal
Every href the dashboard renders from project data passes one shared safeUrl() in ktw-core.js.
Why
hostFileLink/hostTreeLink (ktw-core.js:74,89) only strip a trailing slash from canonical. A foreign project's canonical: "javascript:…" becomes a clickable link (callers ktw-pages.js:84,91,346). There is no CSP in index.html or the server. Found in the maity#1286 M3 review by the integrator.
Scope
safeUrl(u): allowhttps:only; reject userinfo, whitespace, control and bidi characters; return null on reject (the link is then rendered as plain text, not dropped silently: warn once per value).el()routes everyhrefthroughsafeUrl, so new call sites cannot bypass it.- Tests:
javascript:,data:,http:,https://user@host, embedded whitespace/control/bidi, valid https. - Document the rule in the dashboard docs (data contract: canonical must be https).
Out of scope
CSP header (separate decision), routes centre (own card), contrast (own card).
Release
Ships together with the words hook in dashboard 0.11.43. Release only on Oliver's go.
Notes
KTW Coder 001 (ktw-coder-001) ·
Design: one safeUrl for every rendered href
Finding that shapes it: most hrefs the views render are not https:
nav.href(route)gives the host's own internal address (#topic/…,?public=…#…, Maity's paths). An https-only check insideel()would break every nav link. So there are two rules, both inktw-core.js:- `core.safeUrl(u)` (external, from project data), per the card:
- absolute
https:only, with a host; - no userinfo (
https://user@…); - no whitespace, C0/C1 control or bidi characters (U+200E/F, U+202A–E, U+2066–9, U+061C);
- on a reject it returns
nulland callsconsole.warnonce per value (a Set, the same pattern asMISSING_WARNED); - the accepted value comes back unchanged, not
URL.href-normalised, so snapshots stay byte-identical.
- `core.safeHref(u)` (the
el()backstop, everyhrefinsetAttrs):
- a relative reference passes:
#…,?…,/…(but not//…), or a path without a scheme; - anything with a scheme must pass
safeUrl; - the whitespace, control and bidi rule applies to both;
- on a reject,
setAttrsleaveshrefout. The<a>stays with its text and is no longer a link, so it renders as plain text and warns, and nothing is dropped silently. svgElgoes through the samesetAttrs, so it is covered too.
Explicit `safeUrl` at the data sources (so the fallback is chosen deliberately, not just by the backstop):
hostFileLink/hostTreeLink:safeUrl(canonical)first, otherwisenull. Callers already render text fornull(ktw-pages.js:84,91,346,entryHostHref).authorLookup: already requires^https://; it also getssafeUrl, plussafeUrlon theprofilefrom the host's API (app.jsauthorLink).ktw-pages.js:90:canonical/dashboard-statein the config table. http: becomes text (it was a link until now).- Markdown links (
ktw-markdown.js:53): they already require^https://; they now go throughsafeUrl(userinfo, whitespace and bidi too), otherwise the text stays as it is. remoteLink,ref.remote, the friends page'sr.canonical, owner nodes.
Sinks outside `el()`:
nav.open(location.href),window.open(o.url)(graph owner),w.location.hrefinauthorLink. These go throughsafeHref/safeUrland do nothing on a reject.Tests (
node --test, newsafeurl.test.mjs):- the card's cases:
javascript:,JaVaScRiPt:,data:,http:,https://user@host,https://:pw@host,//evil, embedded space, tab, newline, NUL, U+202E and valid https; safeHrefwith internal routes;el("a", {href: "javascript:…"})has no href attribute and warns once;- a fixture state with
canonical: "javascript:alert(1)"in public.mjs: nojavascript:href anywhere on the page.
Docs:
docs/dashboard-views.md, data contract:canonical(and every URL in the data) must be https without userinfo; anything else is shown as text.el()setshrefonly throughsafeHref.- CHANGELOG entry under 0.11.43.
Open point, to decide:
safeHreflets relative references through, because a host's internal routes are relative. A relative URL from data (for examplecanonical: "foo") then becomes a same-origin link. That is harmless, but a link nonetheless. It stays prevented at the source becausehostFileLinketc. requiresafeUrl. OK?