Skip to content

fix(star-rating): check consent link destinations as urls - #7886

Open
ar2rsawseen wants to merge 2 commits into
masterfrom
fix/rating-consent-link-scheme
Open

fix(star-rating): check consent link destinations as urls#7886
ar2rsawseen wants to merge 2 commits into
masterfrom
fix/rating-consent-link-scheme

Conversation

@ar2rsawseen

Copy link
Copy Markdown
Member

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 & — and javascript: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 own escape_html returns it byte for byte:

value after escape_html
<img src=x onerror=alert(1)> &lt;img src=x onerror=alert(1)&gt;
javascript:alert(document.domain)// unchanged
(a+)+$ unchanged

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:

  1. The widget endpoint, in the shared links preprocessor. 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.
  2. The dashboard drawer, which also renders values that have not been saved yet.
  3. The popup page, which shares no code with the dashboard and has its own local helpers.

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 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. Easy to tighten to http(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 //host is 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.05 and countly-platform main, both of which carry the same code. The enterprise plugins repo has no star-rating plugin.

🤖 Generated with Claude Code

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.
@ar2rsawseen

Copy link
Copy Markdown
Member Author

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 countlyCommon's onTagAttr permits those for an href elsewhere in the dashboard. The closer precedent is the surveys widget, which renders the same kind of consent link and already accepts http(s) only, falls back to about:blank, and sets rel on the anchor (plugins/surveys/frontend/public/javascripts/countly.common.components.js). The two widgets sit next to each other and do the same job, so having them disagree on what a link may point at is the outcome worth avoiding.

Dropping relative paths costs nothing here: the popup is served from the Countly server, so /terms would resolve against the server rather than against the site the widget is embedded in.

Also in this commit:

  • both renderers fall back to about:blank rather than an empty href, which would have pointed the link at the current page and reloaded it on click
  • both anchors carry rel="noopener noreferrer"
  • the drawer escapes the href it builds, since that string is handed to v-html and a value that passes the scheme check can still carry a quote
  • the CHANGELOG line no longer mentions relative paths and fragments

Tests updated to match: /terms and #terms moved to the refused set, a whitespace-padded url and a mailto: case added, and two cases assert the render sites use the about:blank fallback and set rel. 68 passing, eslint clean.

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.

1 participant