Skip to content

security(star-rating): authorize the /o?method=star ratings read - #7955

Open
ar2rsawseen wants to merge 1 commit into
masterfrom
security/star-method-authorize
Open

security(star-rating): authorize the /o?method=star ratings read#7955
ar2rsawseen wants to merge 1 commit into
masterfrom
security/star-method-authorize

Conversation

@ar2rsawseen

Copy link
Copy Markdown
Member

Summary

/o?method=star returns the platform and application-version combinations that have received star ratings. It performed no authorization, so any caller who supplies an app_id received that application's data with no api_key, no auth_token and no session.

Measured against the running API with no credentials at all:

GET /o?method=star&app_id=<app>&period=30days
  -> 200  {"Linux":["6:2:1","6:2:0"],"iOS":["1:23"],"tvOS":[...],"MacOS":[...]}

GET /o/feedback/data?app_id=<app>&period=30days        (sibling, same request, no creds)
  -> 400  {"result":"Missing parameter \"api_key\" or \"auth_token\""}

Why it was reachable

Authentication on /o is per method, not global. Every core method authorizes itself (case 'durations'validateUserForDataReadAPI(...), case 'events'validateUserForDataReadAPI(...)), and the default: branch hands the validators to plugins as helpers:

default:
    if (!plugins.dispatch(apiPath, {
        params: params,
        validateUserForDataReadAPI: validateUserForDataReadAPI,   // passed, never called
        ...
    })) { common.returnMessage(params, 400, 'Invalid path, ...'); }

dispatch returns truthy when a plugin claims the request, and the only fallback is 400 Invalid path when nothing does. So a plugin registering on /o must authorize the request itself. Every other plugin does (views/api/api.js:879validateRead at :886; times-of-day:177 → :185), and this plugin does it in its own siblings (/o/feedback/data:1258 → :1302, /o/feedback/widgets:1420 → :1422). The method === 'star' branch claimed the request, returned true, and never called a validator.

This was an omission rather than a design choice: the only caller is the dashboard Ratings page (starRatingPlugin.requestPlatformVersion in plugins/star-rating/frontend/public/javascripts/countly.models.js), which sends the session credential and the active app. The plugin's deliberately public endpoints are a separate, clearly named family (/o/sdk, /feedback/widgets, /i/feedback/input).

Impact

Cross-application disclosure of a specific customer's platform and application-version inventory, to an unauthenticated caller who knows the app_id. Rating comments and detailed feedback live in /o/feedback/data, which is authorized and unaffected. Medium under SECURITY.md.

Fix

Wrap the branch in validateRead(params, FEATURE_NAME, …), the same check the sibling reads already apply. Authorization runs before the period parameter is validated, so an unauthorized caller cannot probe the endpoint through its error responses.

app_id needs no extra guard: the core /o case rejects a request without one, and this branch only concatenates it into a collection name, so there is no ObjectID() conversion that could throw for a global admin (whose validateRead does not require app_id). The diff is mostly re-indentation from introducing the callback.

Scope of the class

Every plugin /o handler in countly-server, countly-platform and countly-enterprise-plugins was swept for a missing validator, and the surviving read candidates were probed unauthenticated. method=star is the only unauthenticated tenant-data read. The other credential-less responders return hardcoded lookup tables that ignore app_id (/o/langmap, /o/sources), are widget configuration for end-user SDKs (/o/feedback/widget), or are the intentional hooks API-endpoint trigger (/o/hooks). /o/surveys/*, /o/calculated_metrics/* and /o/journey-engine/folders all authorize correctly.

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

🤖 Generated with Claude Code

The star-rating dashboard read performed no authorization. Authentication on /o is
per method: every core method calls validateUserForDataReadAPI itself, and the
default branch hands the validators to plugins as helpers without calling them, so a
plugin that claims a request is responsible for authorizing it. This branch claimed
the request, returned true, and never called a validator, so the endpoint answered
callers with no account, token or session, for any app_id they supplied.

What it disclosed is the set of platform and application-version combinations that
have received ratings for that application. Rating comments and the detailed feedback
in /o/feedback/data were not affected; those reads are authorized.

Wrap the branch in validateRead(params, FEATURE_NAME, ...), which is the same check
the sibling reads in this file already apply (/o/feedback/data and
/o/feedback/widgets). Authorization runs before the period parameter is validated, so
an unauthorized caller cannot probe the endpoint through its error responses.

app_id needs no extra guard here: the core /o case rejects a request without one, and
this branch only concatenates it into a collection name, so there is no ObjectID
conversion that could throw for a global admin whose validateRead call does not
require app_id.

The only caller is the dashboard Ratings page
(plugins/star-rating/frontend/public/javascripts/countly.models.js,
starRatingPlugin.requestPlatformVersion), which sends the session credential and an
app_id the member has access to, so it is unaffected.

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

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