Resolve a picked element's authored interaction templates into a URL and a
copy string (issue #1917).
Pure: takes a scene node and the pick's values, returns strings. All DOM
work — opening the link, the context menu, the clipboard — lives in
canvas-actions.ts, so everything security-relevant here is unit-testable
without a browser.
Threat model
A .luxar.zarr can be served from anywhere, and .zattrs is JSON the
viewer did not author. BOTH halves of a link are therefore hostile input:
the template (author-controlled) and the values substituted into it
(data-controlled, one per element). The Python writer runs an equivalent
check at authoring time, but that only covers stores written by Luxar —
nothing stops a hand-edited .zattrs, so the viewer re-validates rather
than trusting the producer.
The guarantees, in the order they are established:
Substituted values are encodeURIComponent-escaped, so a label can
contribute content to a URL but never structure — no injected query,
fragment, path segment or authority. (utils/hover-template.ts.)
The result is parsed with new URL(built) and no base, so a relative
template throws instead of resolving against the viewer's own origin. A
third-party store must not be able to aim a click at an embedder's site.
The scheme must be http: or https: — an ALLOWLIST. This is
deliberately stricter than the denylist OverlayManager.sanitizeHtml
uses for rendered markup: that decides what to display, this decides
where to navigate, and the safe set for navigation is small and known.
Length is capped, so a hostile store cannot push an unbounded string at
the browser after per-element substitution.
One bounded gap is accepted knowingly, because closing it costs more than it
buys: . and .. are unreserved characters, so encodeURIComponent
leaves them intact and the URL parser then normalizes them away — a label of
exactly ".." turns https://site/entry/{hover_label} into https://site/.
That is a wrong destination, not a boundary crossing: the origin is fixed by
the template, a label is a single path segment (its slashes ARE encoded), so
the worst reachable outcome is the origin root. Escaping is therefore about
structure, not about pinning the exact path.
Resolve a picked element's authored interaction templates into a URL and a copy string (issue #1917).
Pure: takes a scene node and the pick's values, returns strings. All DOM work — opening the link, the context menu, the clipboard — lives in
canvas-actions.ts, so everything security-relevant here is unit-testable without a browser.Threat model
A
.luxar.zarrcan be served from anywhere, and.zattrsis JSON the viewer did not author. BOTH halves of a link are therefore hostile input: the template (author-controlled) and the values substituted into it (data-controlled, one per element). The Python writer runs an equivalent check at authoring time, but that only covers stores written by Luxar — nothing stops a hand-edited.zattrs, so the viewer re-validates rather than trusting the producer.The guarantees, in the order they are established:
encodeURIComponent-escaped, so a label can contribute content to a URL but never structure — no injected query, fragment, path segment or authority. (utils/hover-template.ts.)new URL(built)and no base, so a relative template throws instead of resolving against the viewer's own origin. A third-party store must not be able to aim a click at an embedder's site.http:orhttps:— an ALLOWLIST. This is deliberately stricter than the denylistOverlayManager.sanitizeHtmluses for rendered markup: that decides what to display, this decides where to navigate, and the safe set for navigation is small and known.One bounded gap is accepted knowingly, because closing it costs more than it buys:
.and..are unreserved characters, soencodeURIComponentleaves them intact and the URL parser then normalizes them away — a label of exactly".."turnshttps://site/entry/{hover_label}intohttps://site/. That is a wrong destination, not a boundary crossing: the origin is fixed by the template, a label is a single path segment (its slashes ARE encoded), so the worst reachable outcome is the origin root. Escaping is therefore about structure, not about pinning the exact path.