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: if not user:
return RedirectResponse("/auth/login?provider=local", status_code=302) 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 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 # Build breadcrumb if navigating into a folder
breadcrumb = [] breadcrumb = []
current_folder_id = folder 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. If ?folder=ID is provided, returns only that folder's children.
Otherwise returns the full recursive tree from root. 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 ws_id = ws["id"] if ws else None
if not ws_id: if not ws_id:
return {"tree": [], "breadcrumb": []} return {"tree": [], "breadcrumb": []}
@@ -777,7 +781,7 @@ async def create_local_workspace_item(request: Request):
item_type = body.get("type", "page") item_type = body.get("type", "page")
parent_id = body.get("parent_id") parent_id = body.get("parent_id")
explicit_ws_id = body.get("workspace_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_id = explicit_ws_id or (ws["id"] if ws else None)
ws_key = ws["workspace_key"] if ws and not explicit_ws_id else "" ws_key = ws["workspace_key"] if ws and not explicit_ws_id else ""
section = 'Workspace' if item_type == 'folder' else 'Private' section = 'Workspace' if item_type == 'folder' else 'Private'
@@ -867,7 +871,7 @@ async def upload_local_workspace_file(request: Request):
import json import json
from pathlib import Path 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 ws_id = ws["id"] if ws else None
if not ws_id: if not ws_id:
return JSONResponse({"error": "No active workspace"}, status_code=400) return JSONResponse({"error": "No active workspace"}, status_code=400)
@@ -940,7 +944,7 @@ async def upload_local_workspace_folder(request: Request):
import json import json
from pathlib import Path 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 ws_id = ws["id"] if ws else None
if not ws_id: if not ws_id:
return JSONResponse({"error": "No active workspace"}, status_code=400) 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" WORKSPACE_COOKIE = "flowdeck_workspace"
def _get_active_workspace(request: Request) -> dict | None: def _get_active_workspace(request: Request, user_id: int = None) -> dict | None:
"""Get the active workspace ID from the cookie, or first workspace, or 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) ws_id = request.cookies.get(WORKSPACE_COOKIE)
if ws_id: if ws_id:
try: try:
with get_conn() as conn: 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: 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): except (ValueError, Exception):
pass pass
# Fallback: first workspace # Fallback: first workspace owned by this user
with get_conn() as conn: if user_id:
ws = conn.execute("SELECT * FROM workspaces ORDER BY id LIMIT 1").fetchone() with get_conn() as conn:
if ws: ws = conn.execute(
return dict(ws) "SELECT * FROM workspaces WHERE owner_id=? ORDER BY id LIMIT 1",
(user_id,)
).fetchone()
if ws:
return dict(ws)
return None return None
@@ -1091,7 +1103,7 @@ async def list_workspaces(request: Request):
for r in rows: for r in rows:
d = dict(r) d = dict(r)
workspaces.append(d) 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} 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") @router.get("/api/local-workspace/tags")
async def list_tags(request: Request): async def list_tags(request: Request):
"""List tags used in the active workspace (current user's tags only).""" """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 ws_id = ws["id"] if ws else None
uid = _get_user_id(request) uid = _get_user_id(request)
if not ws_id: 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") @router.get("/api/local-workspace/tags/search")
async def search_by_tags(request: Request, tags: str = ""): async def search_by_tags(request: Request, tags: str = ""):
"""Search items by tags (comma-separated).""" """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 ws_id = ws["id"] if ws else None
if not ws_id: if not ws_id:
return {"items": []} return {"items": []}