From cb3666dd7b817df98d5b4a1d18cdd8adf1e4d782 Mon Sep 17 00:00:00 2001 From: ajaysi Date: Mon, 25 May 2026 17:23:59 +0530 Subject: [PATCH] fix: multi-tenant isolation for asset serving, image-studio ownership check, ts compile error --- backend/api/assets_serving.py | 12 ++++++++++ backend/api/images.py | 22 +++++++++++++++++-- frontend/src/contexts/SubscriptionContext.tsx | 2 +- 3 files changed, 33 insertions(+), 3 deletions(-) diff --git a/backend/api/assets_serving.py b/backend/api/assets_serving.py index 3cdf73f2..baa42cec 100644 --- a/backend/api/assets_serving.py +++ b/backend/api/assets_serving.py @@ -38,6 +38,15 @@ MIME_MAP = { } +def _verify_ownership(url_user_id: str, current_user: Dict[str, Any]) -> str: + """Verify the URL user_id matches the authenticated user. Returns sanitized user_id.""" + raw = current_user.get("id") or current_user.get("user_id") or current_user.get("clerk_user_id") + authed_id = str(raw) if raw else "" + if not authed_id or sanitize_user_id(url_user_id) != sanitize_user_id(authed_id): + raise HTTPException(status_code=403, detail="Access denied: user mismatch") + return sanitize_user_id(url_user_id) + + def _resolve_asset_path(user_id: str, category: str, filename: str) -> Path: """Resolve asset path in user workspace with path-traversal protection.""" safe_user_id = sanitize_user_id(user_id) @@ -67,6 +76,7 @@ async def serve_avatar( """Serve avatar images. Supports auth via Authorization header or ?token= query param. Falls back to images/ directory for backward compatibility with old asset library entries.""" require_authenticated_user(current_user) + _verify_ownership(user_id, current_user) safe_filename = os.path.basename(filename) file_path = _resolve_asset_path(user_id, "avatars", safe_filename) @@ -95,6 +105,7 @@ async def serve_voice_sample( which cannot send Authorization headers. """ require_authenticated_user(current_user) + _verify_ownership(user_id, current_user) safe_filename = os.path.basename(filename) file_path = _resolve_asset_path(user_id, "voice_samples", safe_filename) @@ -117,6 +128,7 @@ async def serve_image( ): """Serve generated/uploaded images. Supports auth via Authorization header or ?token= query param.""" require_authenticated_user(current_user) + _verify_ownership(user_id, current_user) safe_filename = os.path.basename(filename) file_path = _resolve_asset_path(user_id, "images", safe_filename) diff --git a/backend/api/images.py b/backend/api/images.py index ab89b90b..7ca2a1ba 100644 --- a/backend/api/images.py +++ b/backend/api/images.py @@ -27,6 +27,8 @@ from services.subscription import UsageTrackingService, PricingService from models.subscription_models import APIProvider, UsageSummary from utils.asset_tracker import save_asset_to_library from utils.file_storage import save_file_safely, generate_unique_filename, sanitize_filename +from services.content_asset_service import ContentAssetService +from models.content_asset_models import ContentAsset router = APIRouter(prefix="/api/images", tags=["images"]) @@ -1022,13 +1024,29 @@ def edit( @router.get("/image-studio/images/{image_filename:path}") async def serve_image_studio_image( image_filename: str, - current_user: Dict[str, Any] = Depends(get_current_user) + current_user: Dict[str, Any] = Depends(get_current_user), + db: Session = Depends(get_db), ): - """Serve a generated or edited image from Image Studio.""" + """Serve a generated or edited image from Image Studio. + Verifies the authenticated user owns the image via asset library lookup.""" try: if not current_user: raise HTTPException(status_code=401, detail="Authentication required") + user_id = current_user.get("id") or current_user.get("user_id") or current_user.get("clerk_user_id") + if not user_id: + raise HTTPException(status_code=401, detail="User ID not found") + + # Verify ownership: the requesting user must have a content_assets record for this file_url + full_url = f"/api/images/image-studio/images/{image_filename}" + service = ContentAssetService(db) + owned = db.query(ContentAsset).filter( + ContentAsset.user_id == user_id, + ContentAsset.file_url == full_url, + ).first() + if not owned: + raise HTTPException(status_code=403, detail="Access denied: image not found in your library") + # Determine if it's an edited image or regular image base_dir = Path(__file__).parent.parent image_studio_dir = (base_dir / "image_studio_images").resolve() diff --git a/frontend/src/contexts/SubscriptionContext.tsx b/frontend/src/contexts/SubscriptionContext.tsx index 871cc30c..0de2c168 100644 --- a/frontend/src/contexts/SubscriptionContext.tsx +++ b/frontend/src/contexts/SubscriptionContext.tsx @@ -151,7 +151,7 @@ export const SubscriptionProvider: React.FC = ({ chil if (process.env.NODE_ENV === 'development') console.log('SubscriptionContext: Checking subscription for user:', userId); const response = await apiClient.get(`/api/subscription/status/${userId}`); - const subscriptionData = response.data.data; + let subscriptionData = response.data.data; if (process.env.NODE_ENV === 'development') console.log('SubscriptionContext: Subscription data received:', { active: subscriptionData?.active, plan: subscriptionData?.plan });