Skip to content

Commit 5ea4cfc

Browse files
andifilhohubautofix-ci[bot]carlosrcoelho
authored
fix: add missing ownership checks in projects API (GHSA-rpf3-3973-4gjr) (#12462)
* fix: enforce ownership check on flow assignment and paginated project read (GHSA-rpf3-3973-4gjr) Prevent authenticated users from reassigning flows they don't own by adding `Flow.user_id == current_user.id` to the UPDATE statements in `create_project`. Also fix the paginated path in `read_project` which was missing the same filter, allowing cross-user flow exfiltration via the `?page=&size=` query parameters. * test: add security regression tests for GHSA-rpf3-3973-4gjr - test_create_project_cannot_steal_other_users_flow: asserts that flows_list in create_project does not move flows owned by another user - test_read_project_paginated_does_not_leak_other_users_flows: asserts that paginated GET /projects/{id} only returns flows owned by the requesting user * test: improve security test naming and style for project ownership checks Remove advisory IDs from test names and docstrings, rename helper and test functions to match the existing codebase conventions. * [autofix.ci] apply automated fixes * fix: address ruff linting errors in ownership security tests * test: add coverage for legitimate flows_list and paginated read assignments --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Carlos Coelho <80289056+carlosrcoelho@users.noreply.github.com>
1 parent cde9f23 commit 5ea4cfc

2 files changed

Lines changed: 138 additions & 3 deletions

File tree

src/backend/base/langflow/api/v1/projects.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -116,13 +116,17 @@ async def create_project(
116116

117117
if project.components_list:
118118
update_statement_components = (
119-
update(Flow).where(Flow.id.in_(project.components_list)).values(folder_id=new_project.id) # type: ignore[attr-defined]
119+
update(Flow)
120+
.where(Flow.id.in_(project.components_list), Flow.user_id == current_user.id) # type: ignore[attr-defined]
121+
.values(folder_id=new_project.id)
120122
)
121123
await session.exec(update_statement_components)
122124

123125
if project.flows_list:
124126
update_statement_flows = (
125-
update(Flow).where(Flow.id.in_(project.flows_list)).values(folder_id=new_project.id) # type: ignore[attr-defined]
127+
update(Flow)
128+
.where(Flow.id.in_(project.flows_list), Flow.user_id == current_user.id) # type: ignore[attr-defined]
129+
.values(folder_id=new_project.id)
126130
)
127131
await session.exec(update_statement_flows)
128132

@@ -192,7 +196,7 @@ async def read_project(
192196
try:
193197
# Check if pagination is explicitly requested by the user (both page and size provided)
194198
if page is not None and size is not None:
195-
stmt = select(Flow).where(Flow.folder_id == project_id)
199+
stmt = select(Flow).where(Flow.folder_id == project_id, Flow.user_id == current_user.id)
196200

197201
if Flow.updated_at is not None:
198202
stmt = stmt.order_by(Flow.updated_at.desc()) # type: ignore[attr-defined]

src/backend/tests/unit/api/v1/test_projects.py

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1795,3 +1795,134 @@ async def test_download_project_sanitizes_windows_path_characters(
17951795
assert "\\" not in file_names[0]
17961796
assert ".." not in file_names[0]
17971797
assert file_names[0].endswith(".json")
1798+
1799+
1800+
async def _create_other_user(client: AsyncClient) -> tuple[str, dict]:
1801+
from langflow.services.auth.utils import get_password_hash
1802+
from langflow.services.database.models.user.model import User
1803+
1804+
user_id = str(uuid4())
1805+
username = f"other_user_{user_id[:8]}"
1806+
async with session_scope() as session:
1807+
user = User(
1808+
username=username,
1809+
password=get_password_hash("testpassword"), # pragma: allowlist secret
1810+
is_active=True,
1811+
is_superuser=False,
1812+
)
1813+
session.add(user)
1814+
await session.commit()
1815+
await session.refresh(user)
1816+
created_id = str(user.id)
1817+
1818+
response = await client.post(
1819+
"api/v1/login", data={"username": username, "password": "testpassword"}
1820+
) # pragma: allowlist secret
1821+
assert response.status_code == 200
1822+
token = response.json()["access_token"]
1823+
return created_id, {"Authorization": f"Bearer {token}"}
1824+
1825+
1826+
async def test_create_project_does_not_reassign_other_users_flows(
1827+
client: AsyncClient,
1828+
logged_in_headers: dict,
1829+
):
1830+
"""Test that flows_list in create_project only moves flows owned by the requesting user."""
1831+
_, other_user_headers = await _create_other_user(client)
1832+
1833+
flow_resp = await client.post(
1834+
"api/v1/flows/",
1835+
json={"name": "user-flow", "data": {}},
1836+
headers=logged_in_headers,
1837+
)
1838+
assert flow_resp.status_code == status.HTTP_201_CREATED
1839+
flow_id = flow_resp.json()["id"]
1840+
original_folder_id = flow_resp.json()["folder_id"]
1841+
1842+
proj_resp = await client.post(
1843+
"api/v1/projects/",
1844+
json={"name": "other-project", "flows_list": [flow_id]},
1845+
headers=other_user_headers,
1846+
)
1847+
assert proj_resp.status_code == status.HTTP_201_CREATED
1848+
other_project_id = proj_resp.json()["id"]
1849+
1850+
flow_after = await client.get(f"api/v1/flows/{flow_id}", headers=logged_in_headers)
1851+
assert flow_after.status_code == status.HTTP_200_OK
1852+
assert flow_after.json()["folder_id"] == original_folder_id
1853+
1854+
proj_detail = await client.get(f"api/v1/projects/{other_project_id}", headers=other_user_headers)
1855+
assert proj_detail.status_code == status.HTTP_200_OK
1856+
assert len(proj_detail.json().get("flows", [])) == 0
1857+
1858+
1859+
async def test_read_project_paginated_only_returns_current_users_flows(
1860+
client: AsyncClient,
1861+
logged_in_headers: dict,
1862+
):
1863+
"""Test that paginated GET /projects/{id} does not return flows owned by other users."""
1864+
_, other_user_headers = await _create_other_user(client)
1865+
1866+
flow_resp = await client.post(
1867+
"api/v1/flows/",
1868+
json={"name": "user-flow-paginated", "data": {}},
1869+
headers=logged_in_headers,
1870+
)
1871+
assert flow_resp.status_code == status.HTTP_201_CREATED
1872+
flow_id = flow_resp.json()["id"]
1873+
original_folder_id = flow_resp.json()["folder_id"]
1874+
1875+
proj_resp = await client.post(
1876+
"api/v1/projects/",
1877+
json={"name": "other-project-paginated", "flows_list": [flow_id]},
1878+
headers=other_user_headers,
1879+
)
1880+
assert proj_resp.status_code == status.HTTP_201_CREATED
1881+
other_project_id = proj_resp.json()["id"]
1882+
1883+
paginated = await client.get(
1884+
f"api/v1/projects/{other_project_id}",
1885+
params={"page": 1, "size": 50},
1886+
headers=other_user_headers,
1887+
)
1888+
assert paginated.status_code == status.HTTP_200_OK
1889+
items = paginated.json().get("flows", {}).get("items", [])
1890+
assert all(item["id"] != flow_id for item in items)
1891+
1892+
flow_after = await client.get(f"api/v1/flows/{flow_id}", headers=logged_in_headers)
1893+
assert flow_after.json()["folder_id"] == original_folder_id
1894+
1895+
1896+
async def test_create_project_with_own_flows_assigns_them_correctly(
1897+
client: AsyncClient,
1898+
logged_in_headers: dict,
1899+
):
1900+
"""Test that flows_list in create_project correctly assigns flows owned by the requesting user."""
1901+
flow_resp = await client.post(
1902+
"api/v1/flows/",
1903+
json={"name": "my-flow", "data": {}},
1904+
headers=logged_in_headers,
1905+
)
1906+
assert flow_resp.status_code == status.HTTP_201_CREATED
1907+
flow_id = flow_resp.json()["id"]
1908+
1909+
proj_resp = await client.post(
1910+
"api/v1/projects/",
1911+
json={"name": "my-project", "flows_list": [flow_id]},
1912+
headers=logged_in_headers,
1913+
)
1914+
assert proj_resp.status_code == status.HTTP_201_CREATED
1915+
project_id = proj_resp.json()["id"]
1916+
1917+
flow_after = await client.get(f"api/v1/flows/{flow_id}", headers=logged_in_headers)
1918+
assert flow_after.status_code == status.HTTP_200_OK
1919+
assert flow_after.json()["folder_id"] == project_id
1920+
1921+
paginated = await client.get(
1922+
f"api/v1/projects/{project_id}",
1923+
params={"page": 1, "size": 50},
1924+
headers=logged_in_headers,
1925+
)
1926+
assert paginated.status_code == status.HTTP_200_OK
1927+
items = paginated.json().get("flows", {}).get("items", [])
1928+
assert any(item["id"] == flow_id for item in items)

0 commit comments

Comments
 (0)