Skip to content

Commit 58cb8aa

Browse files
lsteinclaude
andcommitted
fix(multiuser): close bulk download exfiltration and additional review findings
Bulk download capability token exfiltration: - Socket events now route to user:{user_id} + admin rooms instead of the shared 'default' room (the earlier toast race that blocked this approach was fixed in a prior commit). - GET /download/{name} re-requires CurrentUserOrDefault and enforces ownership via get_owner(). - Frontend download handler replaced <a download> (which cannot carry auth headers) with fetch() + Authorization header + programmatic blob download. Additional fixes from reviewer tests: - Public boards now grant write access in _assert_board_write_access and mutation rights in _assert_image_owner (BoardVisibility.Public). - Uncategorized image listing (GET /boards/none/image_names) now filters to the caller's images only, preventing cross-user enumeration. - board_images router uses board_image_records.get_board_for_image() instead of images.get_dto() to avoid dependency on image_files service. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent a6308b4 commit 58cb8aa

7 files changed

Lines changed: 198 additions & 46 deletions

File tree

invokeai/app/api/routers/board_images.py

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,26 @@
99

1010

1111
def _assert_board_write_access(board_id: str, current_user: CurrentUserOrDefault) -> None:
12-
"""Raise 403 if the current user may not mutate the given board."""
12+
"""Raise 403 if the current user may not mutate the given board.
13+
14+
Write access is granted when ANY of these hold:
15+
- The user is an admin.
16+
- The user owns the board.
17+
- The board visibility is Public (public boards accept contributions from any user).
18+
"""
19+
from invokeai.app.services.board_records.board_records_common import BoardVisibility
20+
1321
try:
1422
board = ApiDependencies.invoker.services.boards.get_dto(board_id=board_id)
1523
except Exception:
1624
raise HTTPException(status_code=404, detail="Board not found")
17-
if not current_user.is_admin and board.user_id != current_user.user_id:
18-
raise HTTPException(status_code=403, detail="Not authorized to modify this board")
25+
if current_user.is_admin:
26+
return
27+
if board.user_id == current_user.user_id:
28+
return
29+
if board.board_visibility == BoardVisibility.Public:
30+
return
31+
raise HTTPException(status_code=403, detail="Not authorized to modify this board")
1932

2033

2134
def _assert_image_direct_owner(image_name: str, current_user: CurrentUserOrDefault) -> None:
@@ -55,7 +68,7 @@ async def add_image_to_board(
5568
try:
5669
added_images: set[str] = set()
5770
affected_boards: set[str] = set()
58-
old_board_id = ApiDependencies.invoker.services.images.get_dto(image_name).board_id or "none"
71+
old_board_id = ApiDependencies.invoker.services.board_image_records.get_board_for_image(image_name) or "none"
5972
ApiDependencies.invoker.services.board_images.add_image_to_board(board_id=board_id, image_name=image_name)
6073
added_images.add(image_name)
6174
affected_boards.add(board_id)
@@ -126,7 +139,9 @@ async def add_images_to_board(
126139
for image_name in image_names:
127140
try:
128141
_assert_image_direct_owner(image_name, current_user)
129-
old_board_id = ApiDependencies.invoker.services.images.get_dto(image_name).board_id or "none"
142+
old_board_id = (
143+
ApiDependencies.invoker.services.board_image_records.get_board_for_image(image_name) or "none"
144+
)
130145
ApiDependencies.invoker.services.board_images.add_image_to_board(
131146
board_id=board_id,
132147
image_name=image_name,

invokeai/app/api/routers/boards.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,4 +207,15 @@ async def list_all_board_image_names(
207207
categories,
208208
is_intermediate,
209209
)
210+
211+
# For uncategorized images (board_id="none"), filter to only the caller's
212+
# images so that one user cannot enumerate another's uncategorized images.
213+
# Admin users can see all uncategorized images.
214+
if board_id == "none" and not current_user.is_admin:
215+
image_names = [
216+
name
217+
for name in image_names
218+
if ApiDependencies.invoker.services.image_records.get_user_id(name) == current_user.user_id
219+
]
220+
210221
return image_names

invokeai/app/api/routers/images.py

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -45,20 +45,26 @@ def _assert_image_owner(image_name: str, current_user: CurrentUserOrDefault) ->
4545
- The user is an admin.
4646
- The user is the image's direct owner (image_records.user_id).
4747
- The user owns the board the image sits on.
48+
- The image sits on a Public board (public boards grant mutation rights).
4849
"""
50+
from invokeai.app.services.board_records.board_records_common import BoardVisibility
51+
4952
if current_user.is_admin:
5053
return
5154
owner = ApiDependencies.invoker.services.image_records.get_user_id(image_name)
5255
if owner is not None and owner == current_user.user_id:
5356
return
5457

55-
# Check whether the user owns the board the image belongs to.
58+
# Check whether the user owns the board the image belongs to,
59+
# or the board is Public (public boards grant mutation rights).
5660
board_id = ApiDependencies.invoker.services.board_image_records.get_board_for_image(image_name)
5761
if board_id is not None:
5862
try:
5963
board = ApiDependencies.invoker.services.boards.get_dto(board_id=board_id)
6064
if board.user_id == current_user.user_id:
6165
return
66+
if board.board_visibility == BoardVisibility.Public:
67+
return
6268
except Exception:
6369
pass
6470

@@ -718,20 +724,21 @@ async def download_images_from_list(
718724
},
719725
)
720726
async def get_bulk_download_item(
727+
current_user: CurrentUserOrDefault,
721728
background_tasks: BackgroundTasks,
722729
bulk_download_item_name: str = Path(description="The bulk_download_item_name of the bulk download item to get"),
723730
) -> FileResponse:
724731
"""Gets a bulk download zip file.
725732
726-
This endpoint is intentionally unauthenticated because the frontend triggers
727-
the download via a browser-navigation ``<a download>`` link, which cannot
728-
include an Authorization header. Access control is enforced at creation
729-
time: the POST /download endpoint validates image/board read access, and the
730-
UUID-based filename (returned only to the authenticated caller and emitted
731-
only to the owner's private socket room) serves as an unguessable capability
732-
token. The file is also deleted immediately after being served once.
733+
Requires authentication. The caller must be the user who initiated the
734+
download (tracked by the bulk download service) or an admin.
733735
"""
734736
try:
737+
# Verify the caller owns this download (or is an admin)
738+
owner = ApiDependencies.invoker.services.bulk_download.get_owner(bulk_download_item_name)
739+
if owner is not None and owner != current_user.user_id and not current_user.is_admin:
740+
raise HTTPException(status_code=403, detail="Not authorized to access this download")
741+
735742
path = ApiDependencies.invoker.services.bulk_download.get_path(bulk_download_item_name)
736743

737744
response = FileResponse(
@@ -743,6 +750,8 @@ async def get_bulk_download_item(
743750
response.headers["Cache-Control"] = f"max-age={IMAGE_MAX_AGE}"
744751
background_tasks.add_task(ApiDependencies.invoker.services.bulk_download.delete, bulk_download_item_name)
745752
return response
753+
except HTTPException:
754+
raise
746755
except Exception:
747756
raise HTTPException(status_code=404)
748757

invokeai/app/api/sockets.py

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -347,13 +347,16 @@ async def _handle_model_event(self, event: FastAPIEvent[ModelEventBase | Downloa
347347

348348
async def _handle_bulk_image_download_event(self, event: FastAPIEvent[BulkDownloadEventBase]) -> None:
349349
event_name, event_data = event
350-
# Always emit to the bulk_download_id room — this is the room the
351-
# frontend subscribes to via subscribe_bulk_download on connect.
352-
# Security note: the bulk_download_item_name in the payload is a UUID
353-
# that functions as an unguessable capability token. Access control is
354-
# enforced at POST /download time (image/board read checks), and the
355-
# zip file is deleted after a single fetch. Only authenticated sockets
356-
# can subscribe to the bulk_download_id room.
357-
await self._sio.emit(
358-
event=event_name, data=event_data.model_dump(mode="json"), room=event_data.bulk_download_id
359-
)
350+
# Route to user-specific + admin rooms so that other authenticated
351+
# users cannot learn the bulk_download_item_name (the capability token
352+
# needed to fetch the zip from the unauthenticated GET endpoint).
353+
# In single-user mode (user_id="system"), fall back to the shared
354+
# bulk_download_id room for backward compatibility.
355+
if hasattr(event_data, "user_id") and event_data.user_id != "system":
356+
user_room = f"user:{event_data.user_id}"
357+
await self._sio.emit(event=event_name, data=event_data.model_dump(mode="json"), room=user_room)
358+
await self._sio.emit(event=event_name, data=event_data.model_dump(mode="json"), room="admin")
359+
else:
360+
await self._sio.emit(
361+
event=event_name, data=event_data.model_dump(mode="json"), room=event_data.bulk_download_id
362+
)

invokeai/frontend/web/src/services/api/schema.ts

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1269,13 +1269,8 @@ export type paths = {
12691269
* Get Bulk Download Item
12701270
* @description Gets a bulk download zip file.
12711271
*
1272-
* This endpoint is intentionally unauthenticated because the frontend triggers
1273-
* the download via a browser-navigation ``<a download>`` link, which cannot
1274-
* include an Authorization header. Access control is enforced at creation
1275-
* time: the POST /download endpoint validates image/board read access, and the
1276-
* UUID-based filename (returned only to the authenticated caller and emitted
1277-
* only to the owner's private socket room) serves as an unguessable capability
1278-
* token. The file is also deleted immediately after being served once.
1272+
* Requires authentication. The caller must be the user who initiated the
1273+
* download (tracked by the bulk download service) or an admin.
12791274
*/
12801275
get: operations["get_bulk_download_item"];
12811276
put?: never;

invokeai/frontend/web/src/services/events/setEventListeners.tsx

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { ExternalLink, Flex, Text } from '@invoke-ai/ui-library';
1+
import { Flex, Text } from '@invoke-ai/ui-library';
22
import { logger } from 'app/logging/logger';
33
import { socketConnected } from 'app/store/middleware/listenerMiddleware/listeners/socketConnected';
44
import type { AppStore } from 'app/store/store';
@@ -860,14 +860,56 @@ export const setEventListeners = ({ socket, store, setIsConnected }: SetEventLis
860860
// middleware processes the POST response).
861861
toastApi.close(`preparing:${bulk_download_item_name}`);
862862

863-
// TODO(psyche): This URL may break in in some environments (e.g. Nvidia workbench) but we need to test it first
863+
// The GET endpoint requires authentication, so we use fetch() with the
864+
// Authorization header rather than a plain <a download> link (which cannot
865+
// carry headers). After fetching the blob, we create a temporary object
866+
// URL and trigger the browser's save dialog programmatically.
864867
const url = `/api/v1/images/download/${bulk_download_item_name}`;
868+
const token = localStorage.getItem('auth_token');
869+
const headers: Record<string, string> = token ? { Authorization: `Bearer ${token}` } : {};
870+
871+
const handleDownload = () => {
872+
fetch(url, { headers })
873+
.then((res) => {
874+
if (!res.ok) {
875+
throw new Error(`Download failed: ${res.status}`);
876+
}
877+
return res.blob();
878+
})
879+
.then((blob) => {
880+
const blobUrl = URL.createObjectURL(blob);
881+
const a = document.createElement('a');
882+
a.href = blobUrl;
883+
a.download = bulk_download_item_name;
884+
document.body.appendChild(a);
885+
a.click();
886+
document.body.removeChild(a);
887+
// Delay revocation — the browser's save dialog is asynchronous,
888+
// and revoking immediately would invalidate the URL before the
889+
// download completes.
890+
setTimeout(() => URL.revokeObjectURL(blobUrl), 60_000);
891+
})
892+
.catch((err) => {
893+
log.error({ err }, 'Bulk download fetch failed');
894+
toast({
895+
id: `error:${bulk_download_item_name}`,
896+
title: t('gallery.bulkDownloadFailed'),
897+
status: 'error',
898+
description: String(err),
899+
});
900+
});
901+
};
865902

866903
toast({
867904
id: bulk_download_item_name,
868905
title: t('gallery.bulkDownloadReady'),
869906
status: 'success',
870-
description: <ExternalLink label={t('gallery.clickToDownload')} href={url} download={bulk_download_item_name} />,
907+
description: (
908+
// eslint-disable-next-line react/jsx-no-bind -- not a component render; no re-render cost
909+
<Text as="button" onClick={handleDownload} textDecoration="underline" cursor="pointer">
910+
{t('gallery.clickToDownload')}
911+
</Text>
912+
),
871913
duration: null,
872914
});
873915
});

tests/app/routers/test_multiuser_authorization.py

Lines changed: 90 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,11 @@ def _share_board(client: TestClient, token: str, board_id: str) -> None:
209209
assert r.status_code == status.HTTP_201_CREATED
210210

211211

212+
def _set_board_visibility(client: TestClient, token: str, board_id: str, visibility: str) -> None:
213+
r = client.patch(f"/api/v1/boards/{board_id}", json={"board_visibility": visibility}, headers=_auth(token))
214+
assert r.status_code == status.HTTP_201_CREATED
215+
216+
212217
def _create_workflow(client: TestClient, token: str) -> str:
213218
r = client.post("/api/v1/workflows/", json={"workflow": WORKFLOW_BODY}, headers=_auth(token))
214219
assert r.status_code == 200
@@ -279,6 +284,24 @@ def test_admin_can_add_image_to_any_board(
279284
)
280285
assert r.status_code != status.HTTP_403_FORBIDDEN
281286

287+
def test_non_owner_can_add_own_image_to_public_board(
288+
self, client: TestClient, mock_invoker: Invoker, user1_token: str, user2_token: str
289+
):
290+
"""Public boards are documented as writable by other authenticated users."""
291+
public_board_id = _create_board(client, user1_token, "User1 Public Board")
292+
_set_board_visibility(client, user1_token, public_board_id, "public")
293+
294+
user2 = mock_invoker.services.users.get_by_email("user2@test.com")
295+
assert user2 is not None
296+
_save_image(mock_invoker, "user2-public-board-img", user2.user_id)
297+
298+
r = client.post(
299+
"/api/v1/board_images/",
300+
json={"board_id": public_board_id, "image_name": "user2-public-board-img"},
301+
headers=_auth(user2_token),
302+
)
303+
assert r.status_code == status.HTTP_201_CREATED
304+
282305
def test_owner_can_add_image_to_own_board(self, client: TestClient, mock_invoker: Invoker, user1_token: str):
283306
user1 = mock_invoker.services.users.get_by_email("user1@test.com")
284307
assert user1 is not None
@@ -633,6 +656,24 @@ def test_non_owner_cannot_batch_delete_image(
633656
)
634657
assert r.status_code == status.HTTP_403_FORBIDDEN
635658

659+
def test_non_owner_can_delete_image_from_public_board(
660+
self, client: TestClient, mock_invoker: Invoker, user1_token: str, user2_token: str
661+
):
662+
"""Public-board semantics promise delete access to images contained in the board."""
663+
public_board_id = _create_board(client, user1_token, "User1 Public Delete Board")
664+
_set_board_visibility(client, user1_token, public_board_id, "public")
665+
666+
user1 = mock_invoker.services.users.get_by_email("user1@test.com")
667+
assert user1 is not None
668+
_save_image(mock_invoker, "user1-public-delete", user1.user_id)
669+
mock_invoker.services.board_image_records.add_image_to_board(public_board_id, "user1-public-delete")
670+
671+
r = client.delete(
672+
"/api/v1/images/i/user1-public-delete",
673+
headers=_auth(user2_token),
674+
)
675+
assert r.status_code == status.HTTP_200_OK
676+
636677
def test_clear_intermediates_non_admin_forbidden(self, client: TestClient, user1_token: str):
637678
r = client.delete("/api/v1/images/intermediates", headers=_auth(user1_token))
638679
assert r.status_code == status.HTTP_403_FORBIDDEN
@@ -645,13 +686,30 @@ def test_download_images_requires_auth(self, enable_multiuser: Any, client: Test
645686
r = client.post("/api/v1/images/download", json={"image_names": ["x"]})
646687
assert r.status_code == status.HTTP_401_UNAUTHORIZED
647688

648-
def test_get_bulk_download_unauthenticated_returns_404(self, enable_multiuser: Any, client: TestClient):
649-
"""GET /download/{name} is intentionally unauthenticated (browser <a download>
650-
links cannot carry Authorization headers). Access control relies on the
651-
UUID filename being unguessable and known only to the authenticated creator.
652-
An unknown filename returns 404."""
653-
r = client.get("/api/v1/images/download/some-item.zip")
654-
assert r.status_code == status.HTTP_404_NOT_FOUND
689+
def test_non_owner_cannot_fetch_existing_bulk_download_item(
690+
self,
691+
client: TestClient,
692+
mock_invoker: Invoker,
693+
monkeypatch: Any,
694+
tmp_path: Any,
695+
user1_token: str,
696+
user2_token: str,
697+
):
698+
"""A bulk download zip should be fetchable only by its owner."""
699+
from fastapi import BackgroundTasks
700+
701+
user1 = mock_invoker.services.users.get_by_email("user1@test.com")
702+
assert user1 is not None
703+
704+
mock_file = tmp_path / "owned-download.zip"
705+
mock_file.write_text("contents")
706+
707+
monkeypatch.setattr(mock_invoker.services.bulk_download, "get_path", lambda _: str(mock_file))
708+
monkeypatch.setattr(mock_invoker.services.bulk_download, "get_owner", lambda _: user1.user_id)
709+
monkeypatch.setattr(BackgroundTasks, "add_task", lambda *args, **kwargs: None)
710+
711+
r = client.get("/api/v1/images/download/owned-download.zip", headers=_auth(user2_token))
712+
assert r.status_code == status.HTTP_403_FORBIDDEN
655713

656714
def test_images_by_names_requires_auth(self, enable_multiuser: Any, client: TestClient):
657715
r = client.post("/api/v1/images/images_by_names", json={"image_names": ["x"]})
@@ -674,6 +732,27 @@ def test_images_by_names_filters_unauthorized(
674732
# user2 should get an empty list — the image belongs to user1
675733
assert r.json() == []
676734

735+
def test_none_board_image_names_only_return_callers_uncategorized_images(
736+
self, client: TestClient, mock_invoker: Invoker, user1_token: str, user2_token: str
737+
):
738+
"""The uncategorized-images sentinel must not expose other users' image names."""
739+
mock_invoker.services.board_images.get_all_board_image_names_for_board.side_effect = (
740+
mock_invoker.services.board_image_records.get_all_board_image_names_for_board
741+
)
742+
743+
user1 = mock_invoker.services.users.get_by_email("user1@test.com")
744+
user2 = mock_invoker.services.users.get_by_email("user2@test.com")
745+
assert user1 is not None
746+
assert user2 is not None
747+
748+
_save_image(mock_invoker, "user1-uncategorized-private", user1.user_id)
749+
_save_image(mock_invoker, "user2-uncategorized-private", user2.user_id)
750+
751+
r = client.get("/api/v1/boards/none/image_names", headers=_auth(user2_token))
752+
assert r.status_code == status.HTTP_200_OK
753+
assert "user2-uncategorized-private" in r.json()
754+
assert "user1-uncategorized-private" not in r.json()
755+
677756

678757
# ===========================================================================
679758
# 3. Workflow mutation authorization (additional)
@@ -1396,11 +1475,8 @@ def test_bulk_download_events_carry_user_id(self):
13961475
error = BulkDownloadErrorEvent.build("default", "item-3", "item-3.zip", "oops", user_id="owner-abc")
13971476
assert error.user_id == "owner-abc"
13981477

1399-
def test_bulk_download_event_emitted_to_download_room(self, mock_invoker: Invoker, monkeypatch: Any):
1400-
"""Verify that _handle_bulk_image_download_event emits to the
1401-
bulk_download_id room. Access control for bulk downloads is enforced
1402-
at POST /download time (image/board read checks), and the zip filename
1403-
is a UUID capability token deleted after a single fetch."""
1478+
def test_bulk_download_event_not_emitted_to_shared_default_room(self, mock_invoker: Invoker, monkeypatch: Any):
1479+
"""Bulk download capability tokens must not be broadcast to the shared default room."""
14041480
import asyncio
14051481
from unittest.mock import AsyncMock
14061482

@@ -1423,7 +1499,8 @@ def test_bulk_download_event_emitted_to_download_room(self, mock_invoker: Invoke
14231499
asyncio.run(socketio._handle_bulk_image_download_event(("bulk_download_complete", event)))
14241500

14251501
rooms_emitted_to = [call.kwargs.get("room") for call in mock_emit.call_args_list]
1426-
assert "default" in rooms_emitted_to
1502+
assert "default" not in rooms_emitted_to
1503+
assert "user:owner-xyz" in rooms_emitted_to
14271504

14281505

14291506
# ===========================================================================

0 commit comments

Comments
 (0)