Skip to content

Commit 9e7354d

Browse files
lsteinclaude
andcommitted
fix(multiuser): validate image access in recall parameter resolution
The recall endpoint loaded image files and ran ControlNet preprocessors on any image_name supplied in control_layers or ip_adapters without checking that the caller could read the image. An attacker who knew another user's image UUID could extract dimensions and, for supported preprocessors, mint a derived processed image they could then fetch. Added _assert_recall_image_access() which validates read access for every image referenced in the request before any resolution or processing occurs. Access is granted to the image owner, admins, or when the image sits on a Shared/Public board. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 9703812 commit 9e7354d

2 files changed

Lines changed: 136 additions & 0 deletions

File tree

invokeai/app/api/routers/recall_parameters.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,51 @@ def resolve_ip_adapter_models(ip_adapters: list[IPAdapterRecallParameter]) -> li
292292
return resolved_adapters
293293

294294

295+
def _assert_recall_image_access(parameters: "RecallParameter", current_user: CurrentUserOrDefault) -> None:
296+
"""Validate that the caller can read every image referenced in the recall parameters.
297+
298+
Control layers and IP adapters may reference image_name fields. Without this
299+
check an attacker who knows another user's image UUID could use the recall
300+
endpoint to extract image dimensions and — for ControlNet preprocessors — mint
301+
a derived processed image they can then fetch.
302+
"""
303+
from invokeai.app.services.board_records.board_records_common import BoardVisibility
304+
305+
image_names: list[str] = []
306+
if parameters.control_layers:
307+
for layer in parameters.control_layers:
308+
if layer.image_name is not None:
309+
image_names.append(layer.image_name)
310+
if parameters.ip_adapters:
311+
for adapter in parameters.ip_adapters:
312+
if adapter.image_name is not None:
313+
image_names.append(adapter.image_name)
314+
315+
if not image_names:
316+
return
317+
318+
# Admin can access all images
319+
if current_user.is_admin:
320+
return
321+
322+
for image_name in image_names:
323+
owner = ApiDependencies.invoker.services.image_records.get_user_id(image_name)
324+
if owner is not None and owner == current_user.user_id:
325+
continue
326+
327+
# Check board visibility
328+
board_id = ApiDependencies.invoker.services.board_image_records.get_board_for_image(image_name)
329+
if board_id is not None:
330+
try:
331+
board = ApiDependencies.invoker.services.boards.get_dto(board_id=board_id)
332+
if board.board_visibility in (BoardVisibility.Shared, BoardVisibility.Public):
333+
continue
334+
except Exception:
335+
pass
336+
337+
raise HTTPException(status_code=403, detail=f"Not authorized to access image {image_name}")
338+
339+
295340
@recall_parameters_router.post(
296341
"/{queue_id}",
297342
operation_id="update_recall_parameters",
@@ -330,6 +375,10 @@ async def update_recall_parameters(
330375
"""
331376
logger = ApiDependencies.invoker.services.logger
332377

378+
# Validate image access before processing — prevents information leakage
379+
# (dimensions) and derived-image minting via ControlNet preprocessors.
380+
_assert_recall_image_access(parameters, current_user)
381+
333382
try:
334383
# Get only the parameters that were actually provided (non-None values)
335384
provided_params = {k: v for k, v in parameters.model_dump().items() if v is not None}

tests/app/routers/test_multiuser_authorization.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1014,6 +1014,93 @@ def test_update_recall_parameters_requires_auth(self, enable_multiuser: Any, cli
10141014
assert r.status_code == status.HTTP_401_UNAUTHORIZED
10151015

10161016

1017+
# ===========================================================================
1018+
# 7a2. Recall parameters image access control
1019+
# ===========================================================================
1020+
1021+
1022+
class TestRecallImageAccess:
1023+
"""Tests that recall parameter image references are validated for read access."""
1024+
1025+
def test_recall_controlnet_with_other_users_image_rejected(
1026+
self, client: TestClient, mock_invoker: Invoker, user1_token: str, user2_token: str
1027+
):
1028+
"""User2 must not be able to reference user1's private image in a control layer."""
1029+
user1 = mock_invoker.services.users.get_by_email("user1@test.com")
1030+
assert user1 is not None
1031+
_save_image(mock_invoker, "victim-ctrl-img", user1.user_id)
1032+
1033+
r = client.post(
1034+
"/api/v1/recall/default",
1035+
json={"control_layers": [{"model_name": "some-controlnet", "image_name": "victim-ctrl-img"}]},
1036+
headers=_auth(user2_token),
1037+
)
1038+
assert r.status_code == status.HTTP_403_FORBIDDEN
1039+
1040+
def test_recall_ip_adapter_with_other_users_image_rejected(
1041+
self, client: TestClient, mock_invoker: Invoker, user1_token: str, user2_token: str
1042+
):
1043+
"""User2 must not be able to reference user1's private image in an IP adapter."""
1044+
user1 = mock_invoker.services.users.get_by_email("user1@test.com")
1045+
assert user1 is not None
1046+
_save_image(mock_invoker, "victim-ip-img", user1.user_id)
1047+
1048+
r = client.post(
1049+
"/api/v1/recall/default",
1050+
json={"ip_adapters": [{"model_name": "some-ip-adapter", "image_name": "victim-ip-img"}]},
1051+
headers=_auth(user2_token),
1052+
)
1053+
assert r.status_code == status.HTTP_403_FORBIDDEN
1054+
1055+
def test_recall_own_image_allowed(self, client: TestClient, mock_invoker: Invoker, user1_token: str):
1056+
"""Owner should be able to reference their own image in recall parameters."""
1057+
user1 = mock_invoker.services.users.get_by_email("user1@test.com")
1058+
assert user1 is not None
1059+
_save_image(mock_invoker, "own-ctrl-img", user1.user_id)
1060+
1061+
r = client.post(
1062+
"/api/v1/recall/default",
1063+
json={"control_layers": [{"model_name": "some-controlnet", "image_name": "own-ctrl-img"}]},
1064+
headers=_auth(user1_token),
1065+
)
1066+
# Should not be 403 (may fail downstream for other reasons, e.g. model not found)
1067+
assert r.status_code != status.HTTP_403_FORBIDDEN
1068+
1069+
def test_recall_shared_board_image_allowed(
1070+
self, client: TestClient, mock_invoker: Invoker, user1_token: str, user2_token: str
1071+
):
1072+
"""An image on a shared board should be usable in recall by any user."""
1073+
user1 = mock_invoker.services.users.get_by_email("user1@test.com")
1074+
assert user1 is not None
1075+
_save_image(mock_invoker, "shared-recall-img", user1.user_id)
1076+
1077+
board_id = _create_board(client, user1_token, "Shared Recall Board")
1078+
_share_board(client, user1_token, board_id)
1079+
mock_invoker.services.board_image_records.add_image_to_board(board_id=board_id, image_name="shared-recall-img")
1080+
1081+
r = client.post(
1082+
"/api/v1/recall/default",
1083+
json={"ip_adapters": [{"model_name": "some-ip-adapter", "image_name": "shared-recall-img"}]},
1084+
headers=_auth(user2_token),
1085+
)
1086+
assert r.status_code != status.HTTP_403_FORBIDDEN
1087+
1088+
def test_recall_admin_can_reference_any_image(
1089+
self, client: TestClient, mock_invoker: Invoker, admin_token: str, user1_token: str
1090+
):
1091+
"""Admin should be able to reference any user's image."""
1092+
user1 = mock_invoker.services.users.get_by_email("user1@test.com")
1093+
assert user1 is not None
1094+
_save_image(mock_invoker, "admin-recall-img", user1.user_id)
1095+
1096+
r = client.post(
1097+
"/api/v1/recall/default",
1098+
json={"control_layers": [{"model_name": "some-controlnet", "image_name": "admin-recall-img"}]},
1099+
headers=_auth(admin_token),
1100+
)
1101+
assert r.status_code != status.HTTP_403_FORBIDDEN
1102+
1103+
10171104
# ===========================================================================
10181105
# 7b. Recall parameters cross-user isolation
10191106
# ===========================================================================

0 commit comments

Comments
 (0)