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
This commit is contained in:
+29
-17
@@ -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": []}
|
||||||
|
|||||||
Reference in New Issue
Block a user