fix: prevent cross-user workspace leak via stale cookie
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped

- _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
This commit is contained in:
2026-07-14 16:09:03 -04:00
parent 364560c84b
commit de86457f90
+29 -17
View File
@@ -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": []}