Skip to content

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

Open
ar2rsawseen wants to merge 2 commits into
release.24.05from
backport/rating-consent-link-scheme-2405
Open

fix(star-rating): check consent link destinations as urls (24.05)#7887
ar2rsawseen wants to merge 2 commits into
release.24.05from
backport/rating-consent-link-scheme-2405

Conversation

@ar2rsawseen

Copy link
Copy Markdown
Member

Backport of #7886 to release.24.05, which carries the same code. See that PR for the full analysis.

Consent links are rendered into anchors in the drawer preview and the public popup, and 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 cannot cover this: 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.

Checked in three places, because they are three runtime contexts that have to agree or a value refused in one renders in another: the widget endpoint (in the shared links preprocessor, which both create and edit run), the dashboard drawer, and the popup page, which shares no code with the dashboard.

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.

Accepted: http(s), a root-relative path, or a fragment, matching what countlyCommon's onTagAttr already permits for an href elsewhere. Leading whitespace and control characters are stripped first, since a browser ignores them when resolving a url, and protocol-relative //host is refused.

Also escapes link labels before building the regular expression they are matched with: unescaped, a label is a pattern, and one like (a+)+$ turns matching into catastrophic backtracking.

Notes on this branch

The api and drawer changes applied unchanged. The popup template differs here, so that part was hand-authored to match.

58 unit tests, all passing here. Full unit suite 101 passing before, 159 after, same 5 pre-existing failures. eslint clean.

🤖 Generated with Claude Code

ar2rsawseen and others added 2 commits August 4, 2026 19:34
Backport of the master change. The api and drawer parts applied unchanged; the
popup template differs on this branch and was hand-authored to match.

Consent links are rendered into anchors in the drawer preview and the public popup,
and nothing checked that a destination was a usable url. Both paths escape the
value, which is no protection here: 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.

Checked at the widget endpoint (in the shared links preprocessor, which both create
and edit run), in the drawer, and in the popup. The render-side checks are not
redundant: widgets stored before this change still carry whatever was saved.

Accepted: http(s), a root-relative path, or a fragment, matching what
countlyCommon's onTagAttr already permits for an href elsewhere. Leading whitespace
and control characters are stripped first, since a browser ignores them when
resolving a url.

Also escapes link labels before building the regular expression they are matched
with, since an unescaped label is a pattern and one like `(a+)+$` causes
catastrophic backtracking.

58 unit tests, all passing on this branch. Full unit suite 101 passing before, 159
after, same 5 pre-existing failures. 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