mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-25 07:12:20 +01:00
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:
co-authored by
isharak7m
PR #6166 Reviewer
parent
501059805f
commit
6fceb6ccd3
+28
-12
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user