Keep the Why: Board

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

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

  1. 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 inside el() would break every nav link. So there are two rules, both in ktw-core.js:

    1. `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 null and calls console.warn once per value (a Set, the same pattern as MISSING_WARNED);
    • the accepted value comes back unchanged, not URL.href-normalised, so snapshots stay byte-identical.
    1. `core.safeHref(u)` (the el() backstop, every href in setAttrs):
    • 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, setAttrs leaves href out. 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.
    • svgEl goes through the same setAttrs, 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, otherwise null. Callers already render text for null (ktw-pages.js:84,91,346, entryHostHref).
    • authorLookup: already requires ^https://; it also gets safeUrl, plus safeUrl on the profile from the host's API (app.js authorLink).
    • ktw-pages.js:90: canonical/dashboard-state in 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 through safeUrl (userinfo, whitespace and bidi too), otherwise the text stays as it is.
    • remoteLink, ref.remote, the friends page's r.canonical, owner nodes.

    Sinks outside `el()`: nav.open (location.href), window.open(o.url) (graph owner), w.location.href in authorLink. These go through safeHref/safeUrl and do nothing on a reject.

    Tests (node --test, new safeurl.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;
    • safeHref with internal routes;
    • el("a", {href: "javascript:…"}) has no href attribute and warns once;
    • a fixture state with canonical: "javascript:alert(1)" in public.mjs: no javascript: 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() sets href only through safeHref.
    • CHANGELOG entry under 0.11.43.

    Open point, to decide: safeHref lets relative references through, because a host's internal routes are relative. A relative URL from data (for example canonical: "foo") then becomes a same-origin link. That is harmless, but a link nonetheless. It stays prevented at the source because hostFileLink etc. require safeUrl. OK?