From de86457f90b0b6aaead25dfd0749340632ff037d Mon Sep 17 00:00:00 2001 From: bruno Date: Tue, 14 Jul 2026 16:09:03 -0400 Subject: [PATCH] fix: prevent cross-user workspace leak via stale cookie - _get_active_workspace now verifies workspace owner matches current user - Fallback: only picks workspace owned by current user (not any user) - /local-workspace redirects to /workspaces when no workspace exists - New users no longer see other users' workspaces/projects --- app/routers/dashboard.py | 46 +++++++++++++++++++++++++--------------- 1 file changed, 29 insertions(+), 17 deletions(-) diff --git a/app/routers/dashboard.py b/app/routers/dashboard.py index ef07482..b77b41e 100644 --- a/app/routers/dashboard.py +++ b/app/routers/dashboard.py @@ -581,9 +581,13 @@ async def local_workspace_page(request: Request, folder: int = None): if not user: return RedirectResponse("/auth/login?provider=local", status_code=302) - ws = _get_active_workspace(request) + ws = _get_active_workspace(request, user_id=user["id"]) ws_id = ws["id"] if ws else None + # If no workspace exists for this user, redirect to workspaces page + if not ws_id: + return RedirectResponse("/workspaces", status_code=302) + # Build breadcrumb if navigating into a folder breadcrumb = [] current_folder_id = folder @@ -617,7 +621,7 @@ async def local_workspace_tree(request: Request, folder: int = None): If ?folder=ID is provided, returns only that folder's children. Otherwise returns the full recursive tree from root. """ - ws = _get_active_workspace(request) + ws = _get_active_workspace(request, user_id=_get_user_id(request)) ws_id = ws["id"] if ws else None if not ws_id: return {"tree": [], "breadcrumb": []} @@ -777,7 +781,7 @@ async def create_local_workspace_item(request: Request): item_type = body.get("type", "page") parent_id = body.get("parent_id") explicit_ws_id = body.get("workspace_id") - ws = _get_active_workspace(request) + ws = _get_active_workspace(request, user_id=_get_user_id(request)) ws_id = explicit_ws_id or (ws["id"] if ws else None) ws_key = ws["workspace_key"] if ws and not explicit_ws_id else "" section = 'Workspace' if item_type == 'folder' else 'Private' @@ -867,7 +871,7 @@ async def upload_local_workspace_file(request: Request): import json from pathlib import Path - ws = _get_active_workspace(request) + ws = _get_active_workspace(request, user_id=_get_user_id(request)) ws_id = ws["id"] if ws else None if not ws_id: return JSONResponse({"error": "No active workspace"}, status_code=400) @@ -940,7 +944,7 @@ async def upload_local_workspace_folder(request: Request): import json from pathlib import Path - ws = _get_active_workspace(request) + ws = _get_active_workspace(request, user_id=_get_user_id(request)) ws_id = ws["id"] if ws else None if not ws_id: return JSONResponse({"error": "No active workspace"}, status_code=400) @@ -1043,22 +1047,30 @@ async def upload_local_workspace_folder(request: Request): WORKSPACE_COOKIE = "flowdeck_workspace" -def _get_active_workspace(request: Request) -> dict | None: - """Get the active workspace ID from the cookie, or first workspace, or None.""" +def _get_active_workspace(request: Request, user_id: int = None) -> dict | None: + """Get the active workspace ID from the cookie (verified for current user), or first user workspace, or None.""" ws_id = request.cookies.get(WORKSPACE_COOKIE) if ws_id: try: with get_conn() as conn: - ws = conn.execute("SELECT * FROM workspaces WHERE id=?", (int(ws_id),)).fetchone() + ws = conn.execute("SELECT * FROM workspaces WHERE id=?", + (int(ws_id),)).fetchone() if ws: - return dict(ws) + ws_dict = dict(ws) + # Verify ownership — only return if it belongs to the current user + if user_id is None or ws_dict.get("owner_id") == user_id: + return ws_dict except (ValueError, Exception): pass - # Fallback: first workspace - with get_conn() as conn: - ws = conn.execute("SELECT * FROM workspaces ORDER BY id LIMIT 1").fetchone() - if ws: - return dict(ws) + # Fallback: first workspace owned by this user + if user_id: + with get_conn() as conn: + ws = conn.execute( + "SELECT * FROM workspaces WHERE owner_id=? ORDER BY id LIMIT 1", + (user_id,) + ).fetchone() + if ws: + return dict(ws) return None @@ -1091,7 +1103,7 @@ async def list_workspaces(request: Request): for r in rows: d = dict(r) workspaces.append(d) - active = _get_active_workspace(request) + active = _get_active_workspace(request, user_id=_get_user_id(request)) return {"workspaces": workspaces, "active_id": active["id"] if active else None} @@ -1310,7 +1322,7 @@ async def list_all_tags_global(request: Request): @router.get("/api/local-workspace/tags") async def list_tags(request: Request): """List tags used in the active workspace (current user's tags only).""" - ws = _get_active_workspace(request) + ws = _get_active_workspace(request, user_id=_get_user_id(request)) ws_id = ws["id"] if ws else None uid = _get_user_id(request) if not ws_id: @@ -1392,7 +1404,7 @@ async def remove_item_tag(item_id: int, tag_id: int): @router.get("/api/local-workspace/tags/search") async def search_by_tags(request: Request, tags: str = ""): """Search items by tags (comma-separated).""" - ws = _get_active_workspace(request) + ws = _get_active_workspace(request, user_id=_get_user_id(request)) ws_id = ws["id"] if ws else None if not ws_id: return {"items": []}