Skip to content

fix: scope storage-URI resolve endpoints to the user's organization - #9930

Open
akashjainn wants to merge 1 commit into
HumanSignal:developfrom
akashjainn:fix/resolve-storage-uri-org-scope
Open

fix: scope storage-URI resolve endpoints to the user's organization#9930
akashjainn wants to merge 1 commit into
HumanSignal:developfrom
akashjainn:fix/resolve-storage-uri-org-scope

Conversation

@akashjainn

@akashjainn akashjainn commented Sep 7, 2026

Copy link
Copy Markdown

Fixes #9924.

Problem

GET /tasks/<task_id>/resolve/ and GET /projects/<project_id>/resolve/ fetch the object by bare primary key:

task = Task.objects.get(pk=task_id)
project = Project.objects.get(pk=project_id)

Neither is scoped to an organization, so the only tenant check is ResolveStorageUriAPIMixin.resolve calling instance.has_permission(request.user), which reaches ProjectMixin.has_permission:

return not (self.organization_id and self.organization.has_deleted(user))

Organization.has_deleted matches an OrganizationMember row with deleted_at set. That is true for a membership someone had and lost, and false for a user who was never a member at all, so the check rejects ex-members and admits strangers. Organization.has_permission, defined a few lines below it, is the one that requires an active membership.

The result is that any authenticated user can pass another organization's task or project id and have Label Studio presign or proxy the file from that tenant's connected storage. Ids are sequential, so the set is walkable. The code comment on that permission check explains the assumption it was written under: "LSO always runs a single organization, so cross-org access does not exist by design."

Change

Both lookups now carry the organization, matching how the task and project list endpoints already scope their querysets (tasks/api.py, projects/api.py). A cross-organization id returns 404 and never reaches storage resolution.

Tests

TestResolveStorageUriCrossOrganization in label_studio/io_storages/tests/test_proxy_api.py, four cases: an outside organization is refused on both endpoints, and a member of the owning organization still reaches resolution on both. They patch ResolveStorageUriAPIMixin.resolve and assert on whether it is reached, because a 404 alone does not separate "not your tenant" from "no storage configured for that URI".

Both refusal tests fail against the current code and pass with this change. The two same-organization tests pass either way, so they pin that the scope does not over-block.

Two existing tests asserted the exact lookup call (assert_called_once_with(pk=1)), which is the thing this changes, so they now assert the organization is part of it.

pytest label_studio/io_storages/tests/test_proxy_api.py     41 passed (37 before this change, plus 4 new)
pytest label_studio/io_storages/                            199 passed
pytest label_studio/tasks/tests/test_api.py -k resolve      4 passed
ruff check / ruff format                                    clean

Validated against the reporter's PoC

poc_cross_org_idor.py from #9924, run against this branch and against the same tree with the fix reverted. Two lines changed, the repo path and the response read described below, nothing in the attack itself:

fix reverted with fix
attacker org == victim org False False
referenced file status=200 leaked=True status=404 leaked=False
arbitrary unreferenced key status=200 leaked=True status=404 leaked=False

The reverted column reproduces the reporter's published output exactly. This is a stronger check than the unit tests, which assert on whether resolution is reached: the PoC mocks the storage stream and confirms the bytes themselves come back to the attacker.

One note for anyone else validating with it. The script reads the response as resp.streaming_content if hasattr(...) else resp.content, which works on the vulnerable path because that returns a streaming response, but raises ContentNotRenderedError on a patched build, where a 404 DRF Response has not been rendered yet. Calling resp.render() first is enough. As published it cannot report a successful fix, it throws instead.

The PoC also confirms the arbitrary-key behaviour described below: an unreferenced key in the same bucket returned 200 with data before this change. Organization scoping closes that across tenants, not within one.

Acceptance criteria

  1. In organization A, create a project with a cloud storage connection and import a task whose data references a file in that bucket.
  2. As a user of organization A, open the task in the labeling view. The media loads as before.
  3. Note the task id and the project id from organization A.
  4. Sign in as a user of organization B who has never been a member of organization A.
  5. Request GET /tasks/<task_id_from_A>/resolve/?fileuri=<base64 of the file URI>. Expect 404. Before this change it presigned or proxied the file.
  6. Request GET /projects/<project_id_from_A>/resolve/?fileuri=<same>. Expect 404.
  7. Confirm no regression for a removed member: a user whose membership in organization A was revoked still gets a non-200, as before.

Not covered here

The report also notes that can_resolve_url checks only the scheme and bucket of the supplied fileuri, not that the key is one the task actually references, so a user inside the owning organization can still read unrelated objects from that bucket. That is a different trust boundary and a larger change, so it is left for a follow-up rather than mixed into this fix.

TaskResolveStorageUri and ProjectResolveStorageUri looked the object up by
bare primary key, so the only tenant check left was has_permission:

    return not (self.organization_id and self.organization.has_deleted(user))

has_deleted only matches an OrganizationMember row that has deleted_at set, so
it is true for a revoked membership and false for someone who was never a
member at all. A user from another organization has no such row, the check
returns True, and the request goes on to presign or proxy the object. Ids are
sequential, so any authenticated user could walk another tenant's tasks and
projects and read from their connected bucket.

Add the organization to both lookups, matching how the task and project list
endpoints already scope their querysets. A cross-organization id now returns
404 before storage resolution is reached.

Reported in HumanSignal#9924 (GHSA-8mp9-cpp7-x63q).
@netlify

netlify Bot commented Sep 7, 2026

Copy link
Copy Markdown

👷 Deploy request for label-studio-docs-new-theme pending review.

Visit the deploys page to approve it

Name Link
🔨 Latest commit 85f661a

@netlify

netlify Bot commented Sep 7, 2026

Copy link
Copy Markdown

👷 Deploy request for heartex-docs pending review.

Visit the deploys page to approve it

Name Link
🔨 Latest commit 85f661a

@netlify

netlify Bot commented Sep 7, 2026

Copy link
Copy Markdown

Deploy Preview for label-studio-storybook canceled.

Name Link
🔨 Latest commit 85f661a
🔍 Latest deploy log https://app.netlify.com/projects/label-studio-storybook/deploys/6a9f29c061849b000899134d

@netlify

netlify Bot commented Sep 7, 2026

Copy link
Copy Markdown

Deploy Preview for label-studio-playground canceled.

Name Link
🔨 Latest commit 85f661a
🔍 Latest deploy log https://app.netlify.com/projects/label-studio-playground/deploys/6a9f29c0f88a50000742787d

@github-actions github-actions Bot added the fix label Sep 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

1 participant