fix(security): deny generated-image access when ownership verification fails (#6268)

* fix(security): deny generated-image access when ownership verification fails

The except Exception block in serve_generated_image catches all
exceptions during ownership verification and falls through to serve
the file anyway. If the database is down or corrupted, the ownership
check is bypassed entirely — any authenticated user can download any
generated image by guessing the content-hash filename.

Raise HTTP 503 (Service Unavailable) instead of falling through,
so the endpoint fails closed when verification is impossible. This
matches the defense-in-depth principle: deny access on verification
errors rather than granting it.

* fix(security): enforce fail-closed ownership and secure caching for generated images

- Fail closed on missing gallery records, null/empty owners, and DB errors
- Support both cookie session and bearer API token owners via effective_user
- Add in-memory registry for freshly generated images to preserve access
- Eliminate file existence oracle by verifying ownership before disk probing
- Update Cache-Control to private, no-cache, must-revalidate
- Pass owner attribution through tool execution and image MCP server
- Add comprehensive hermetic regression tests for ownership gates and caching

* fix(security): sanitize tool owner arguments and drop unneeded MCP env fallback

- Remove redundant and unsafe ODYSSEUS_MCP_IMAGE_OWNER fallback in image_gen_server.py to fix 4 tests in test_env_reference.py.
- Sanitize caller-supplied tool arguments across all MCP and email tool dispatch paths in src/tool_execution.py: strip untrusted _odysseus_* keys from arguments.
- Only inject _odysseus_owner when verified trusted server context is present, preventing untrusted/anonymous users from spoofing ownership claims.
- Sanitize empty/whitespace owner strings in image_gen_server.py to None.
- Add comprehensive regression tests in test_generated_image_ownership.py for argument sanitization, owner injection, fallback propagation, and server sanitization.

---------

Co-authored-by: isharak7m <192635824+isharak7m@users.noreply.github.com>
Co-authored-by: PR #6166 Reviewer <reviewer@odysseus-review.local>
This commit is contained in:
Isharak
2026-10-14 12:18:50 +01:00
committed by GitHub
co-authored by isharak7m PR #6166 Reviewer
parent 501059805f
commit 6fceb6ccd3
7 changed files with 643 additions and 39 deletions
+9
View File
@@ -1372,6 +1372,9 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
filename = f"{uuid.uuid4().hex[:12]}.png"
img_path = img_dir / filename
img_path.write_bytes(base64.b64decode(img.get("b64_json")))
if owner:
from src.generated_images import register_generated_image
register_generated_image(filename, owner)
image_url = f"/api/generated-image/{filename}"
image_id = _save_to_gallery(filename)
@@ -1392,6 +1395,9 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
filename = f"{uuid.uuid4().hex[:12]}.png"
img_path = img_dir / filename
img_path.write_bytes(dl_resp.content)
if owner:
from src.generated_images import register_generated_image
register_generated_image(filename, owner)
image_url = f"/api/generated-image/{filename}"
image_id = _save_to_gallery(filename)
else:
@@ -1537,6 +1543,9 @@ async def do_edit_image(
img_dir.mkdir(parents=True, exist_ok=True)
filename = f"{uuid.uuid4().hex[:12]}{suffix}"
(img_dir / filename).write_bytes(image_bytes)
if owner:
from src.generated_images import register_generated_image
register_generated_image(filename, owner)
return f"/api/generated-image/{filename}", _save_edited_image_to_gallery(filename)
async def _try_local_img2img_fallback(client: httpx.AsyncClient) -> Optional[Dict[str, Any]]:
+42 -1
View File
@@ -1,5 +1,8 @@
import re
import threading
import time
from pathlib import Path
from typing import Optional
from fastapi import HTTPException
@@ -12,10 +15,47 @@ GENERATED_IMAGE_RE = re.compile(
r"^[a-f0-9]{8,64}\.(png|jpg|jpeg|webp|gif|mp4|mov|webm|mkv|m4v)$"
)
GENERATED_IMAGE_HEADERS = {
"Cache-Control": "public, max-age=31536000, immutable",
"Cache-Control": "private, no-cache, must-revalidate",
"X-Content-Type-Options": "nosniff",
}
_RECENT_GENERATED_IMAGES: dict[str, tuple[str, float]] = {}
_RECENT_IMAGES_LOCK = threading.Lock()
_RECENT_IMAGES_TTL = 3600 # 1 hour TTL
def register_generated_image(filename: str, owner: Optional[str]) -> None:
"""Record ownership of a freshly generated image.
Ensures legitimate access to freshly generated images even before
or in case Gallery database records are created/committed.
"""
if not filename or not owner:
return
now = time.time()
with _RECENT_IMAGES_LOCK:
if len(_RECENT_GENERATED_IMAGES) > 200:
expired = [k for k, (_, exp) in _RECENT_GENERATED_IMAGES.items() if exp < now]
for k in expired:
_RECENT_GENERATED_IMAGES.pop(k, None)
_RECENT_GENERATED_IMAGES[filename] = (owner, now + _RECENT_IMAGES_TTL)
def get_registered_image_owner(filename: str) -> Optional[str]:
"""Return the owner of a freshly generated image if still valid in the registry."""
if not filename:
return None
now = time.time()
with _RECENT_IMAGES_LOCK:
entry = _RECENT_GENERATED_IMAGES.get(filename)
if not entry:
return None
owner, expiry = entry
if now > expiry:
_RECENT_GENERATED_IMAGES.pop(filename, None)
return None
return owner
def resolve_generated_image_path(filename: str) -> Path:
if not isinstance(filename, str) or not GENERATED_IMAGE_RE.fullmatch(filename):
@@ -27,3 +67,4 @@ def resolve_generated_image_path(filename: str) -> Path:
if not path.exists():
raise HTTPException(status_code=404, detail="Image not found")
return path
+28 -12
View File
@@ -1317,7 +1317,8 @@ def _parse_qualified_mcp_args(tool: str, content: str) -> tuple[Dict, Optional[s
if tool.startswith("mcp__email__"):
return {}, "Email MCP tool arguments must be a JSON object."
return {}, None
return parsed, None
sanitized = {k: v for k, v in parsed.items() if not k.startswith("_odysseus_")}
return sanitized, None
def _parse_generate_image(content: str) -> Dict:
@@ -1402,9 +1403,12 @@ def _build_mcp_args(tool: str, content: str) -> Dict:
except (json.JSONDecodeError, TypeError):
decoded = None
if isinstance(decoded, dict) and any(k in decoded for k in primaries):
return decoded
return {k: v for k, v in decoded.items() if not k.startswith("_odysseus_")}
parser = _MCP_ARG_PARSERS.get(tool)
return parser(content) if parser else {}
res = parser(content) if parser else {}
if isinstance(res, dict):
return {k: v for k, v in res.items() if not k.startswith("_odysseus_")}
return res
def _normalize_mcp_text_error(result: Dict) -> Dict:
@@ -1421,20 +1425,25 @@ async def _call_mcp_tool(
tool: str,
content: str,
progress_cb: Optional[Callable[[Dict], Awaitable[None]]] = None,
owner: Optional[str] = None,
) -> Dict:
"""Route a legacy tool call through the MCP manager, with direct fallbacks."""
bound = active_backend_operation()
if bound is not None and isinstance(bound.resource, NativeBackendResource):
return await _direct_fallback(tool, content, progress_cb=progress_cb) or {"error": f"Native tool '{tool}' unavailable", "exit_code": 1}
return await _direct_fallback(tool, content, progress_cb=progress_cb, owner=owner) or {"error": f"Native tool '{tool}' unavailable", "exit_code": 1}
mcp = get_mcp_manager()
if not mcp:
if bound is not None:
raise ResourceIdentityError("Pinned MCP backend is unavailable")
return await _direct_fallback(tool, content, progress_cb=progress_cb) or {"error": f"MCP manager not available for tool '{tool}'", "exit_code": 1}
return await _direct_fallback(tool, content, progress_cb=progress_cb, owner=owner) or {"error": f"MCP manager not available for tool '{tool}'", "exit_code": 1}
server_id, tool_name = _MCP_TOOL_MAP[tool]
qualified = f"mcp__{server_id}__{tool_name}"
args = _build_mcp_args(tool, content)
if tool == "generate_image":
args.pop("_odysseus_owner", None)
if owner:
args["_odysseus_owner"] = owner
result = await mcp.call_tool(qualified, args)
# Stdio MCP servers can only return TextContent, so an explicit
@@ -1444,7 +1453,7 @@ async def _call_mcp_tool(
# If MCP server not connected, try direct fallback
if bound is None and isinstance(result, dict) and result.get("exit_code") == 1 and "not connected" in result.get("error", ""):
fallback = await _direct_fallback(tool, content, progress_cb=progress_cb)
fallback = await _direct_fallback(tool, content, progress_cb=progress_cb, owner=owner)
if fallback:
return fallback
@@ -2193,7 +2202,7 @@ async def _execute_tool_block_impl(
elif tool in _MCP_TOOL_MAP:
first_line = content.split(chr(10))[0][:80]
desc = f"{tool}: {first_line}"
result = await dispatched(_call_mcp_tool(tool, content, progress_cb=progress_cb))
result = await dispatched(_call_mcp_tool(tool, content, progress_cb=progress_cb, owner=owner))
elif tool in ("grep", "glob", "ls", "get_workspace", "host_shell"):
# Code-navigation tools — no MCP server; run the direct implementation.
first_line = content.split(chr(10))[0][:80]
@@ -2379,7 +2388,7 @@ async def _execute_tool_block_impl(
)
else:
if isinstance(parsed, dict):
args = parsed
args = {k: v for k, v in parsed.items() if not k.startswith("_odysseus_")}
else:
_args_error = (
f"'{tool}' arguments must be a JSON object, "
@@ -2388,11 +2397,12 @@ async def _execute_tool_block_impl(
if _args_error is not None:
result = {"error": _args_error, "exit_code": 1}
else:
args = dict(args)
args.pop(_EMAIL_MCP_OWNER_ARG, None)
args.pop(_EMAIL_MCP_SESSION_ARG, None)
if owner:
args = dict(args)
args[_EMAIL_MCP_OWNER_ARG] = owner
if session_id:
args = dict(args)
args[_EMAIL_MCP_SESSION_ARG] = session_id
result = await dispatched(mcp.call_tool(qualified, args))
else:
@@ -2406,14 +2416,20 @@ async def _execute_tool_block_impl(
if parse_error:
result = {"error": parse_error, "exit_code": 1}
else:
args = dict(args)
args.pop("_odysseus_owner", None)
args.pop("_odysseus_session_id", None)
if tool.startswith("mcp__email__"):
if owner:
args = dict(args)
args[_EMAIL_MCP_OWNER_ARG] = owner
if session_id:
args = dict(args)
args[_EMAIL_MCP_SESSION_ARG] = session_id
elif tool == "mcp__image_gen__generate_image" or tool.startswith("mcp__image_gen__"):
if owner:
args["_odysseus_owner"] = owner
result = _normalize_mcp_text_error(await dispatched(mcp.call_tool(tool, args)))
if tool == "mcp__image_gen__generate_image" or tool.startswith("mcp__image_gen__"):
_promote_image_fields(result)
else:
desc = f"mcp: {tool}"
result = {"error": "MCP manager not available", "exit_code": 1}