Skip to content

security(star-rating): forward only the widget's own parameters from /i/feedback/input - #7952

Open
ar2rsawseen wants to merge 2 commits into
masterfrom
security/feedback-input-param-allowlist
Open

security(star-rating): forward only the widget's own parameters from /i/feedback/input#7952
ar2rsawseen wants to merge 2 commits into
masterfrom
security/feedback-input-param-allowlist

Conversation

@ar2rsawseen

Copy link
Copy Markdown
Member

Summary

/i/feedback/input takes a rating submission from the web feedback widget. The widget cannot compute a checksum, because it does not hold the app's salt, so the handler replays the request into the generic /i processor with no_checksum set. It built that replayed request from the caller's entire query string:

url: "/i?" + ob.params.href.split("/i/feedback/input?")[1]

The handler's only check is on events, which must be a single [CLY]_star_rating event. Every other parameter was forwarded untouched and then processed with checksum verification disabled (checksumSaltVerification skips the check when params.no_checksum is set).

So on an app that has a checksum salt configured, a caller holding only the public app key could append unrelated write parameters and have them accepted unsigned:

  • old_device_id reaches countlyApi.mgmt.appUsers.merge(), merging app users
  • token_session reaches the push token binding in plugins/push/api/api-push.js
  • begin_session, user_details, consent and crash ride along the same way

The same parameters sent straight to /i are rejected, which is the control the reporter used.

Fix

Rebuild the forwarded query from the parameters the widget actually sends — events, app_key, device_id, sdk_name, sdk_version, timestamp, hour, dow, app_version (see the widget request in frontend/public/templates/feedback-popup.html) — and drop everything else. The rating submission keeps working; any other operation has to go through /i and satisfy the checksum.

Two details worth noting in the helper:

  • values are URL encoded, so a parameter value cannot inject a second parameter (there is a test for device_id=d1&old_device_id=victim)
  • non-scalar values are dropped rather than stringified, since a JSON request body can place an object in a query string parameter

The helper lives in plugins/star-rating/api/input-utils.js so it can be unit tested.

Scope of the class

/i/feedback/input is the only unauthenticated no_checksum forwarder. The sole other no_checksum caller is api/utils/taskmanager.js, which replays a stored task URL created by an authenticated user, and it is the only other place that builds a request this way.

Tests

test/unit-tests/star-rating.input-utils.js (8 cases, all passing): the widget's parameters survive intact and events round-trips byte-for-byte; old_device_id, token_session/token, begin_session, end_session, user_details, consent, crash, metrics and ip_address are all dropped; a value cannot inject another parameter; non-scalars are dropped; an empty or absent query is tolerated.

The existing plugin tests in plugins/star-rating/tests.js only vary device_id and never sent old_device_id through this endpoint, so they are unaffected. eslint is clean on all three files.

Reported through the security bug bounty program (received 2026-08-18).

🤖 Generated with Claude Code

ar2rsawseen and others added 2 commits August 18, 2026 14:33
…/i/feedback/input

/i/feedback/input accepts a feedback submission from the web widget, which cannot
compute a checksum because it does not hold the app's salt, so the handler replays
the request into the generic /i processor with no_checksum set. It built that
replayed request from the caller's entire query string:

    url: "/i?" + ob.params.href.split("/i/feedback/input?")[1]

The handler's only check is on the events parameter, which has to be a single
[CLY]_star_rating event. Every other parameter was forwarded untouched and then
processed with checksum verification disabled, so a caller holding just the public
app key could append unrelated write parameters and have them accepted unsigned on
an app that has a checksum salt configured. old_device_id reaches
appUsers.merge(), and token_session reaches the push token binding;
begin_session, user_details, consent and crash ride along the same way.

Rebuild the forwarded query from the parameters the widget actually sends
(events, app_key, device_id, sdk_name, sdk_version, timestamp, hour, dow,
app_version) and drop everything else, so the star rating submission keeps working
while any other operation has to go through /i and satisfy the checksum. Values are
URL encoded, so a parameter value cannot inject a second parameter, and non scalar
values are dropped rather than stringified, since a JSON body can put an object in
a query string parameter.

The helper lives in api/input-utils.js so it can be unit tested; the existing
plugin tests only vary device_id and never sent old_device_id here, so they are
unaffected.

Note this is the only unauthenticated no_checksum forwarder: the sole other
no_checksum caller is api/utils/taskmanager.js, which replays a stored task URL
created by an authenticated user.

Reported through the security bug bounty programme (received 2026-08-18).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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