Skip to content

Commit fddb9ef

Browse files
fix: add ownership scoping and shared flag to saved_views to prevent IDOR
1 parent 35e3db3 commit fddb9ef

3 files changed

Lines changed: 773 additions & 20 deletions

File tree

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
ALTER TABLE saved_views ADD COLUMN owner_id TEXT NOT NULL DEFAULT '';
2+
ALTER TABLE saved_views ADD COLUMN shared INTEGER NOT NULL DEFAULT 0;
3+
CREATE INDEX IF NOT EXISTS idx_saved_views_owner ON saved_views(owner_id);

backend/secuscan/saved_views.py

Lines changed: 20 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,9 @@
11
from __future__ import annotations
2-
32
import json
43
import uuid
54
from typing import Any, Dict, List, Optional
6-
75
from fastapi import APIRouter, Depends, HTTPException
86
from pydantic import BaseModel, Field, field_validator
9-
107
from .auth import get_current_owner, require_api_key
118
from .database import get_db
129

@@ -21,7 +18,6 @@
2118

2219

2320
class FilterPreset(BaseModel):
24-
"""Validated representation of the frontend filter state."""
2521
severity: str = "all"
2622
target: str = "all"
2723
scanner: str = "all"
@@ -46,9 +42,9 @@ def validate_severity(cls, v: str) -> str:
4642

4743

4844
class SavedViewCreate(BaseModel):
49-
"""Request body for POST /saved-views."""
5045
name: str = Field(..., min_length=1, max_length=60)
5146
filter_json: str
47+
shared: bool = False
5248

5349
@field_validator("name")
5450
@classmethod
@@ -70,9 +66,9 @@ def validate_filter_json(cls, v: str) -> str:
7066

7167

7268
class SavedViewUpdate(BaseModel):
73-
"""Request body for PUT /saved-views/{id}."""
7469
name: Optional[str] = Field(None, min_length=1, max_length=60)
7570
filter_json: Optional[str] = None
71+
shared: Optional[bool] = None
7672

7773
@field_validator("name")
7874
@classmethod
@@ -97,12 +93,9 @@ def validate_filter_json(cls, v: Optional[str]) -> Optional[str]:
9793
return v
9894

9995

100-
101-
102-
10396
async def require_owned_saved_view(db, view_id: str, owner: str) -> Dict[str, Any]:
10497
row = await db.fetchone(
105-
"SELECT id, owner_id FROM saved_views WHERE id = ?", (view_id,)
98+
"SELECT id, owner_id, shared FROM saved_views WHERE id = ?", (view_id,)
10699
)
107100
if row is None:
108101
raise HTTPException(status_code=404, detail="Saved view not found")
@@ -115,10 +108,15 @@ async def require_owned_saved_view(db, view_id: str, owner: str) -> Dict[str, An
115108

116109
@saved_views_router.get("")
117110
async def list_saved_views(owner: str = Depends(get_current_owner)) -> Dict[str, Any]:
111+
"""Return views owned by the caller plus all shared views."""
118112
db = await get_db()
119113
rows: List[Dict] = await db.fetchall(
120-
"SELECT id, name, filter_json, created_at, updated_at "
121-
"FROM saved_views WHERE owner_id = ? ORDER BY created_at ASC",
114+
"""
115+
SELECT id, name, filter_json, shared, owner_id, created_at, updated_at
116+
FROM saved_views
117+
WHERE owner_id = ? OR shared = 1
118+
ORDER BY created_at ASC
119+
""",
122120
(owner,),
123121
)
124122
return {"views": rows, "total": len(rows)}
@@ -129,7 +127,6 @@ async def create_saved_view(
129127
body: SavedViewCreate, owner: str = Depends(get_current_owner)
130128
) -> Dict[str, Any]:
131129
db = await get_db()
132-
133130
existing = await db.fetchone(
134131
"SELECT id FROM saved_views WHERE LOWER(name) = LOWER(?) AND owner_id = ?",
135132
(body.name, owner),
@@ -140,10 +137,13 @@ async def create_saved_view(
140137
detail=f"A saved view named '{body.name}' already exists. "
141138
"Use PUT to overwrite it.",
142139
)
143-
144140
view_id = str(uuid.uuid4())
145141
await db.execute(
146-
(view_id, body.name, body.filter_json, owner),
142+
"""
143+
INSERT INTO saved_views (id, name, filter_json, shared, owner_id)
144+
VALUES (?, ?, ?, ?, ?)
145+
""",
146+
(view_id, body.name, body.filter_json, int(body.shared), owner),
147147
)
148148
return {"id": view_id, "name": body.name, "created": True}
149149

@@ -155,14 +155,12 @@ async def update_saved_view(
155155
owner: str = Depends(get_current_owner),
156156
) -> Dict[str, Any]:
157157
db = await get_db()
158-
159158
await require_owned_saved_view(db, view_id, owner)
160159

161160
updates: List[str] = []
162161
params: List[Any] = []
163162

164163
if body.name is not None:
165-
# Check for name collision with a *different* record owned by this caller
166164
collision = await db.fetchone(
167165
"SELECT id FROM saved_views WHERE LOWER(name) = LOWER(?) "
168166
"AND id != ? AND owner_id = ?",
@@ -180,6 +178,10 @@ async def update_saved_view(
180178
updates.append("filter_json = ?")
181179
params.append(body.filter_json)
182180

181+
if body.shared is not None:
182+
updates.append("shared = ?")
183+
params.append(int(body.shared))
184+
183185
if not updates:
184186
raise HTTPException(status_code=400, detail="No fields to update")
185187

@@ -199,16 +201,14 @@ async def delete_saved_view(
199201
view_id: str, owner: str = Depends(get_current_owner)
200202
) -> Dict[str, Any]:
201203
db = await get_db()
202-
203204
row = await db.fetchone(
204205
"SELECT owner_id FROM saved_views WHERE id = ?", (view_id,)
205206
)
206207
if row is not None and row["owner_id"] != owner:
207208
raise HTTPException(
208209
status_code=403, detail="You do not have access to this saved view"
209210
)
210-
211211
await db.execute(
212212
"DELETE FROM saved_views WHERE id = ? AND owner_id = ?", (view_id, owner)
213213
)
214-
return {"id": view_id, "deleted": True}
214+
return {"id": view_id, "deleted": True}

0 commit comments

Comments
 (0)