security(reports): authorize the apps a report targets, on every path that uses them (24.05) - #7865
Open
ar2rsawseen wants to merge 6 commits into
Open
security(reports): authorize the apps a report targets, on every path that uses them (24.05)#7865ar2rsawseen wants to merge 6 commits into
ar2rsawseen wants to merge 6 commits into
Conversation
/i/reports/create and /i/reports/update authorized the target apps only when the effective report_type was "core". A non-core report_type names the plugin that owns the report, and those reports were inserted and updated without any authorization of the object they point at. The authorization path for this already existed but was unreachable. The dashboards plugin implements /report/authorize, which checks view access to the dashboard a report renders, while the reports-side caller that invoked it (validateNonCoreUser) had been commented out. Restores that caller and gates both create and update on its result. Update authorizes the merged stored-plus-payload report rather than the payload alone, so a partial update cannot leave an unauthorized target in place, and repointing an existing report at a different target is checked too. A copy is passed so the authorize flag, which is only how the dispatch returns its result, never reaches the written document. Fails closed, matching the original intent of the commented-out code: a report type whose plugin does not answer /report/authorize is not authorized. Only "dashboards" implements it today, in this repo and in countly-platform, so a plugin adding a new report type needs to implement the event for it. Also drops validateCoreUser, which was commented out and superseded by the inline per-app check that uses the reports feature permission rather than plain app membership. Co-Authored-By: Claude <noreply@anthropic.com>
Real end-to-end coverage through the HTTP endpoints with a real non-admin member, so the whole path is exercised rather than the branch in isolation: validateCreate, the report_type branch, the /report/authorize dispatch into the dashboards plugin, and the insert. - a member with reports rights but no view access to a private dashboard is refused when scheduling a report against it - the same member succeeds for a dashboard they own, which proves the authorize dispatch resolves rather than the request simply failing - the authorize flag does not appear on the stored report - a core report for an app the member has rights on still succeeds, so the restructuring did not change the core path Not executed locally: the harness needs COUNTLY_TEST_API_KEY_ADMIN and COUNTLY_TEST_APP_ID. CI runs them in test-api-plugins. Co-Authored-By: Claude <noreply@anthropic.com>
Dashboard permissions are deliberately separate from app permissions: a dashboard can be shared with a member who has no access to the apps its widgets reference, and they are meant to be able to view it and schedule a report for it. The suite proved a member is refused a dashboard they cannot view, and allowed their own, but nothing covered the case in between, which is the one a tightening change could plausibly break. This uses the same admin-owned dashboard the member was already refused, changing only the share, so the test isolates the share as the deciding factor rather than app rights. Also generalised the cleanup to delete a list of dashboards rather than two hard-coded ids. Co-Authored-By: Claude <noreply@anthropic.com>
…nd rendering it (24.05) Ports the second half of #7864 to this branch. The create and update paths here already authorize the apps a report will have, but the other paths were scoped by owner alone (recordUpdateOrDeleteQuery adds only `user`). Owning a report is not the same as still being allowed to read the apps it covers, so a member who scheduled a report while they held an app, then lost that access while keeping reports rights elsewhere, could: - re-enable it through /i/reports/status and let the schedule resume, - mail themselves that app's figures on demand through /i/reports/send, - read them straight out of the response through /i/reports/preview. Nothing re-checked anything at send time either, so a report enabled before access was revoked kept mailing indefinitely. - status authorizes the stored apps before enabling. Disabling stays allowed: it only reduces what the report does, and refusing would leave someone unable to stop mail they no longer want. - send and preview authorize the stored apps before rendering. - the scheduled job and sendReport both consult reports.ownerMayStillSend, which judges the owner's current access. The job reaches the renderer directly rather than through sendReport, so guarding only sendReport would miss it. last_sent is not stamped when a report is skipped, so it resumes if access is restored. - delete stays owner-only on purpose: it cannot reach another app's data, and the owner needs a way to clean up. The allow-list the create and update paths built inline is extracted to a shared appsArePermitted, rather than repeated three more times. Master carries the same helper, so the two branches now express this the same way. This branch has no /i/reports/pdf endpoint, so that guard from master does not apply here. Two deliberate fail-open choices, to avoid breaking working installations: a report whose owner no longer resolves is logged and still sent, since getReport already falls back to a global admin for it; and reports with no apps pass, because those are non-core reports authorized through /report/authorize instead. Also drops a stray console.log of the status payload. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ion-2405 Resolves the CHANGELOG conflict by keeping both sides' entries, and adds the missing entry for the endpoint and send-time authorization. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A deleted account holds access to nothing, so the rule that stops a report once its owner loses access to the apps it covers applies to a deleted owner too. This branch previously logged a warning and sent anyway, deferring to the global-admin fallback in getReport, which left the one case where nobody at all is authorized as the case that still delivered. That fallback stays where it is, because preview and pdf rely on it and both are authorized against the member making the request. It just no longer decides whether scheduled mail keeps going out. Brings reports in line with alerts, which already stops when the owner cannot be resolved. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> (24.05)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Backport of #7864 to
release.24.05. Applied cleanly; only the CHANGELOG heading needed resolving.What changes
/i/reports/createand/i/reports/updateauthorized the target apps only when the effectivereport_typewas"core". A non-corereport_typenames the plugin that owns the report, and those reports were inserted and updated without any authorization of the object they point at.The authorization path already existed but was unreachable: the dashboards plugin implements
/report/authorize, which checks view access to the dashboard a report renders, while the reports-side caller that invoked it (validateNonCoreUser) had been commented out.Restores that caller and gates create and update on its result. Update authorizes the merged stored-plus-payload report, so a partial update cannot leave an unauthorized target in place and repointing an existing report is checked too. A copy is passed so the
authorizedflag never reaches the written document. Fails closed, matching the original intent: a report type whose plugin does not answer the event is not authorized, and only"dashboards"implements it today.Also drops
validateCoreUser, which was commented out and superseded by the inline per-app check.Tests
Real end-to-end coverage through the HTTP endpoints with a real non-admin member: refused for a private dashboard they cannot view, allowed for one they own, the
authorizedflag absent from the stored report, and a core report still succeeding as a control.Not executed locally, since the harness needs
COUNTLY_TEST_API_KEY_ADMINandCOUNTLY_TEST_APP_ID. CI runs them intest-api-plugins.Second commit: the paths that were still owner-only
recordUpdateOrDeleteQueryadds onlyuser, so beyond create and update the remaining paths were scoped by ownership alone. Owning a report is not the same as still being allowed to read the apps it covers. A member who scheduled a report while they held an app, then lost that access while keeping reports rights elsewhere, could:/i/reports/statusand let the schedule resume,/i/reports/send,/i/reports/preview(and/i/reports/pdfwhere that endpoint exists). This is the most direct of them: no email involved, the figures come back in the HTTP response.Nothing re-checked anything at send time either, so a report enabled before access was revoked kept mailing indefinitely. The scheduled job calls
reports.getReportdirectly rather thansendReport, so both now consult a sharedreports.ownerMayStillSend; guarding onlysendReportwould have missed the scheduled path entirely, which is the one that matters most.Deliberately unchanged, with the reasoning recorded in the code:
last_sentis not stamped when a report is skipped, so it resumes on its own if access is restored.Reports with no apps pass, because those are non-core reports whose target is authorized by their own plugin through
/report/authorizerather than per app.A report whose owner no longer exists also stops being sent. A deleted account holds access to nothing, so the same rule applies to it; the
getReportglobal-admin fallback stays where it is, because preview and pdf rely on it and both are authorized against the member making the request, but it no longer decides whether scheduled mail keeps going out. This matches alerts, which already stopped in that case.Also drops a stray
console.logof the status payload.Worth a look separately
That global-admin fallback for an unresolvable owner quietly raises the access a report renders with.
/i/users/deleteremoves a departing member's reports, so it should only affect orphaned rows, but I left it alone here rather than change send behaviour inside a security fix.Tests for this commit
A suite that revokes a member's access to one app mid-test, covering both directions. The exploit: enable, send and preview are each refused, and the stored report is verified to still be disabled afterwards. The happy paths, which have to keep working: the member can still enable, disable, send and preview reports on the app they do hold, can still switch off and delete the revoked-app report, and a global admin is unaffected.
This branch has no
/i/reports/pdfendpoint, so that guard from #7864 does not apply here. The inline allow-list the create and update paths built is extracted into a sharedappsArePermitted, matching how master expresses it, rather than repeated three more times.One test assertion differs from master deliberately: the preview happy path asserts "not 401" rather than a specific status, because this branch renders with
if (params && params.res) { var html = res.message; ... }and lacks master'selse if (res)guard, so a report for an app with no data can fail inside rendering. That is unrelated to authorization and is left alone.🤖 Generated with Claude Code