Bulk issue merge loses task filters, merges organization-wide, and never terminates
This report is confidential because the background task can resolve a client-controlled merge target outside the caller's organization and move issue hashes and events across organization boundaries.
A bulk issue merge delegated to update_issues_task can lose its selected issue IDs, expand to the organization-wide queryset, move unrelated hashes and events into one target, and never terminate. Reproduced on GlitchTip 6.1; the same code paths are present on current master (dd51bc4).
IssueFilters declares HTTP aliases:
class IssueFilters(Schema):
id__in: list[int] | None = Field(None, alias="id")
project__in: list[int] | None = Field(None, alias="project")The API serializes the schema by field name when enqueueing:
"filter_params": filters.dict()The task reconstructs it through alias validation:
filters = IssueFilters(**filter_params)Keys such as id__in and project__in are silently ignored. The ID, project, start, and end filters become None, so the task operates on every issue in the organization up to the request's max_id.
The merge loop then keeps selecting the same rows. It soft-deletes a source batch but neither excludes deleted issues nor advances a cursor. Each iteration moves another 1,000 events into the target. Once the events are gone, the loop continues spinning on the same issue rows.
A normal merge with at least 50 matches can therefore:
- soft-delete unrelated issues across the organization;
- move project-scoped
IssueHashrows across project boundaries; - route future events into the wrong issue;
- progressively reattach retained events to the target;
- occupy a worker indefinitely.
There are three adjacent problems in the same path:
- A request combining
mergewithstatusorassignedTocan enqueue before the API replaces the raw clientmergevalue with the resolved target ID. The old task resolves that integer through an unscopedIssue.objects.aget(). Caller-organization hashes and events can therefore be attached to an issue in another organization. This is the reason to keep the report confidential initially. - The API immediate path moves at most 1,000 events. With fewer than 50 selected issues no task is enqueued, and with 50 or more the immediate source IDs are excluded from the task. Events beyond the first 1,000 remain attached to soft-deleted sources.
- The same filter loss affects delegated bulk status and assignment updates. Status changes apply organization-wide. The assignment loop also lacks a progress condition and can run indefinitely.
Reproduction
-
Create one organization with two projects. Create an issue in project B first, then more than 50 issues in project A.
-
Bulk merge the project A issues using repeated
idquery parameters. -
Inspect the queued
update_issues_taskpayload. It containsfilter_params.id__in. -
In a Django shell:
IssueFilters(**{"id__in": [1, 2]}).id__in # None IssueFilters(**{"id": [1, 2]}).id__in # [1, 2] -
Run the task. The earlier project B issue is included, and the task does not return.
Stopping the worker does not undo the damage. Once hashes move, future events continue grouping into the wrong target, so this is a data-integrity problem as well as a worker-liveness problem.
Proposed fix
I have a patch and merge request draft ready. I have not opened a public merge request because that would disclose this confidential report. Please let me know whether you prefer a private handoff or want me to open the merge request after public disclosure is approved.