fix(star-rating): check consent link destinations as urls - #7886
fix(star-rating): check consent link destinations as urls#7886ar2rsawseen wants to merge 2 commits into
Conversation
A widget's consent links are rendered into anchors, in the drawer preview and in the public popup. Nothing checked that a destination was a usable url, so anything typed into that field became the href. Both render paths escape the value, and escaping is no protection here. It touches < > and &, and `javascript:alert(1)` contains none of them, so the value arrives unchanged and the browser runs it when the link is clicked. Verified: passing that string through the api's own escape_html returns it byte for byte. A destination has to be checked as a destination. Checked in three places, because there are three runtime contexts and they have to agree or a value refused in one renders in another: - the widget endpoint, in the shared links preprocessor. Both create and edit run every preprocessor, so it is the one place that sees every submitted link on both paths. An unusable destination is emptied rather than the request refused, so a widget still saves and the link simply has nowhere to point. - the dashboard drawer, which also renders values that have not been saved at all. - the popup page, which shares no code with the dashboard. The render-side checks are not redundant with the endpoint one. Widgets stored before this change still carry whatever was saved, and without them those links would keep working until someone re-saved the widget. Accepted: http(s), a root-relative path, or a fragment. That matches what countlyCommon's onTagAttr already permits for an href elsewhere in the dashboard; being stricter than the rest of the dashboard would be a surprise, and a self-hosted install may reasonably point a consent link at a relative Terms page. Leading whitespace and control characters are stripped before the test, because a browser ignores them when resolving a url, so a tab or newline inside the scheme name would otherwise slip past. Protocol-relative "//host" is refused as well. Also escapes link labels before building the regular expression they are matched with. The label was interpolated raw, so it was a pattern rather than a literal, and one like `(a+)+$` turns matching into catastrophic backtracking on a long enough consent text. Escaping is done locally rather than with lodash, which is not a declared global in that file. 58 unit tests: every accepted and refused case asserted against all three copies of the check, including case variations, embedded tab and newline, data and vbscript urls, protocol-relative urls and non-string input, plus the regex escaping. Full unit suite 185 passing before, 243 after, same 2 pre-existing failures (Countly Request, network dependent). eslint clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The surveys widget renders the same kind of consent link and already accepts http(s) only, falling back to about:blank and setting rel on the anchor. The two widgets are configured side by side and do the same job, so they should agree: a destination refused in one and rendered in the other is the surprising outcome, and it is the kind of gap that invites a second look at this later. A relative path is no loss. The popup is served from the Countly server, so "/terms" resolves against the server rather than against the site the widget is embedded in, which is not what anyone configuring it would intend. The renderers now fall back to about:blank rather than an empty href, which would have pointed the link back at the current page and reloaded it on click, and both anchors carry rel="noopener noreferrer". The drawer also escapes the href it builds: that string is handed to v-html, and a value that passes the scheme check can still carry a quote and close the attribute early.
|
Pushed a follow-up commit that narrows the accepted set to http(s) only. The first pass allowed a root-relative path and a fragment as well, on the reasoning that Dropping relative paths costs nothing here: the popup is served from the Countly server, so Also in this commit:
Tests updated to match: |
A widget's consent links are rendered into anchors, in the drawer preview and in the public popup. Nothing checked that a destination was a usable url, so anything typed into that field became the
href.Escaping was never going to cover this
Both render paths escape the value. Escaping touches
<,>and&— andjavascript:alert(1)contains none of them, so it arrives unchanged and the browser runs it when the link is clicked. Passing it through the api's ownescape_htmlreturns it byte for byte:escape_html<img src=x onerror=alert(1)><img src=x onerror=alert(1)>javascript:alert(document.domain)//(a+)+$Escaping controls what a page displays. Where a link points is a different question, and so is what a value means when compiled into a regex. That is why the tag-injection route through the same function is fine while the href is not.
Checked in three places
There are three runtime contexts and they have to agree, or a value refused in one renders in another:
linkspreprocessor. Create and edit both run every preprocessor, so it is the single point that sees every submitted link on both paths. An unusable destination is emptied rather than the request refused, so a widget still saves and the link simply has nowhere to point.The render-side checks are not redundant. Widgets stored before this change still carry whatever was saved; without them those links keep working until someone re-saves the widget.
What is accepted
http(s), a root-relative path, or a fragment — matching whatcountlyCommon'sonTagAttralready permits for anhrefelsewhere in the dashboard. Being stricter than the rest of the dashboard would be a surprise, and a self-hosted install may reasonably point a consent link at a relative Terms page. Easy to tighten tohttp(s)only if preferred.Leading whitespace and control characters are stripped before the test, because a browser ignores them when resolving a url — otherwise a tab or newline inside the scheme name slips past. Protocol-relative
//hostis refused too, since it is not a relative path.Second issue in the same function
Link labels were interpolated raw into
new RegExp(...), so a label was a pattern rather than a literal. One like(a+)+$turns matching into catastrophic backtracking on a long enough consent text. Now escaped, locally rather than via lodash, which is not a declared global in that file.Verification
58 unit tests, with every accepted and refused case asserted against all three copies of the check: case variations, leading spaces, embedded tab and newline inside the scheme,
data:,vbscript:, protocol-relative, empty string, bare scheme, other schemes, and non-string input — plus the regex escaping, including that a lone[or backslash still produces a valid pattern.Full unit suite: 185 passing before, 243 after, same 2 pre-existing failures (
Countly Request, network dependent). Baseline captured by reverting. eslint clean.Propagation
Going to
release.24.05and countly-platformmain, both of which carry the same code. The enterprise plugins repo has no star-rating plugin.🤖 Generated with Claude Code