Description
The Saved Views API endpoints at /api/v1/saved-views have no authentication requirements and no owner-scoped data isolation. Any unauthenticated user (or any authenticated user) can list, create, update, and delete saved views belonging to any other user.
Specific vulnerabilities:
-
GET /api/v1/saved-views (line 98-106): Returns ALL saved views for ALL users without any authentication or filtering. An attacker can enumerate all saved filter configurations, which may contain sensitive information about scanning targets, search queries, and date ranges.
-
POST /api/v1/saved-views (line 109-136): Creates saved views with no owner_id attribution. There is no column for owner_id in the saved_views table (see database.py — no owner_id field in the saved views schema at lines 300+).
-
PUT /api/v1/saved-views/{id} (line 139-182): Updates any saved view by ID. An attacker who can guess or enumerate view IDs can modify others' saved views.
-
DELETE /api/v1/saved-views/{id} (line 185-190): Deletes any saved view by ID. An attacker can delete all saved views in the system.
The router at saved_views.py:12 is mounted WITHOUT the require_api_key dependency that all other routes use:
saved_views_router = APIRouter(prefix="/api/v1/saved-views", tags=["saved-views"])
Compare with routes.py:133:
router = APIRouter(prefix="/api/v1", dependencies=[Depends(require_api_key)])
Steps to Reproduce
- Without any API key or authentication, send:
- Observe the response contains all saved views from all users, including:
{
"views": [
{
"id": "uuid-1",
"name": "Critical vulns on prod",
"filter_json": "{\"severity\":\"critical\",\"target\":\"internal-prod.example.com\",\"searchQuery\":\"admin\"}",
"created_at": "...",
"updated_at": "..."
},
{
"id": "uuid-2",
"name": "Internal network scan targets",
"filter_json": "{\"severity\":\"all\",\"target\":\"10.0.0.0/8\"}",
...
}
],
"total": 47
}
- Delete all saved views:
DELETE /api/v1/saved-views/uuid-1
DELETE /api/v1/saved-views/uuid-2
...
Expected Behavior
Saved views should be:
- Authenticated (require a valid API key or session)
- Scoped to the authenticated user (only return views belonging to the requesting user)
- Protected against unauthorized modification or deletion
Actual Behavior
All saved views endpoints are completely unauthenticated and unscoped.
Root Cause
Two issues compound:
-
Missing authentication: The router at saved_views.py:12 does not include the require_api_key dependency, unlike all other API routes in routes.py:133.
-
Missing owner isolation: The saved_views database schema in database.py does not have an owner_id column. Even if authentication were added, there would be no way to scope views to a specific user.
Current schema (from database.py):
CREATE TABLE IF NOT EXISTS saved_views (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
filter_json TEXT NOT NULL DEFAULT '{}',
created_at TIMESTAMP NOT NULL DEFAULT (datetime('now')),
updated_at TIMESTAMP
);
Proposed Fix
Step 1: Add owner_id Column (Database Migration)
Create a new migration file:
-- migrations/00X_add_saved_views_owner_id.sql
ALTER TABLE saved_views ADD COLUMN owner_id TEXT NOT NULL DEFAULT 'default';
CREATE INDEX IF NOT EXISTS idx_saved_views_owner ON saved_views(owner_id);
-- Migrate existing rows to 'default' owner (or delete them if unsafe)
UPDATE saved_views SET owner_id = 'default' WHERE owner_id IS NULL;
Step 2: Add Authentication to the Router
from .auth import require_api_key, get_current_owner
saved_views_router = APIRouter(
prefix="/api/v1/saved-views",
tags=["saved-views"],
dependencies=[Depends(require_api_key)], # Add auth dependency
)
Step 3: Scope All Queries to Authenticated User
@saved_views_router.get("")
async def list_saved_views(request: Request) -> Dict[str, Any]:
"""Return saved views for the authenticated user."""
owner_id = get_current_owner(request) # Extract from API key / session
db = await get_db()
rows = await db.fetchall(
"SELECT id, name, filter_json, created_at, updated_at "
"FROM saved_views WHERE owner_id = ? ORDER BY created_at ASC",
(owner_id,),
)
return {"views": rows, "total": len(rows)}
@saved_views_router.post("", status_code=201)
async def create_saved_view(body: SavedViewCreate, request: Request) -> Dict[str, Any]:
owner_id = get_current_owner(request)
db = await get_db()
# Check for name collision within this owner's views
existing = await db.fetchone(
"SELECT id FROM saved_views WHERE owner_id = ? AND LOWER(name) = LOWER(?)",
(owner_id, body.name),
)
if existing:
raise HTTPException(status_code=409, detail=f"A saved view named '{body.name}' already exists.")
view_id = str(uuid.uuid4())
await db.execute(
"INSERT INTO saved_views (id, owner_id, name, filter_json) VALUES (?, ?, ?, ?)",
(view_id, owner_id, body.name, body.filter_json),
)
return {"id": view_id, "name": body.name, "created": True}
Similarly scope PUT and DELETE to the authenticated owner's views.
Step 4: Backfill Migration for Existing Deployments
For existing deployments, if the database currently has views from multiple users that cannot be attributed, either:
- Assign all existing views to a single admin account
- Or delete unowned views (safest — the data is filter presets, not critical data)
Description
The Saved Views API endpoints at
/api/v1/saved-viewshave no authentication requirements and no owner-scoped data isolation. Any unauthenticated user (or any authenticated user) can list, create, update, and delete saved views belonging to any other user.Specific vulnerabilities:
GET /api/v1/saved-views(line 98-106): Returns ALL saved views for ALL users without any authentication or filtering. An attacker can enumerate all saved filter configurations, which may contain sensitive information about scanning targets, search queries, and date ranges.POST /api/v1/saved-views(line 109-136): Creates saved views with no owner_id attribution. There is no column forowner_idin thesaved_viewstable (seedatabase.py— noowner_idfield in the saved views schema at lines 300+).PUT /api/v1/saved-views/{id}(line 139-182): Updates any saved view by ID. An attacker who can guess or enumerate view IDs can modify others' saved views.DELETE /api/v1/saved-views/{id}(line 185-190): Deletes any saved view by ID. An attacker can delete all saved views in the system.The router at
saved_views.py:12is mounted WITHOUT therequire_api_keydependency that all other routes use:Compare with
routes.py:133:Steps to Reproduce
{ "views": [ { "id": "uuid-1", "name": "Critical vulns on prod", "filter_json": "{\"severity\":\"critical\",\"target\":\"internal-prod.example.com\",\"searchQuery\":\"admin\"}", "created_at": "...", "updated_at": "..." }, { "id": "uuid-2", "name": "Internal network scan targets", "filter_json": "{\"severity\":\"all\",\"target\":\"10.0.0.0/8\"}", ... } ], "total": 47 }Expected Behavior
Saved views should be:
Actual Behavior
All saved views endpoints are completely unauthenticated and unscoped.
Root Cause
Two issues compound:
Missing authentication: The router at
saved_views.py:12does not include therequire_api_keydependency, unlike all other API routes inroutes.py:133.Missing owner isolation: The
saved_viewsdatabase schema indatabase.pydoes not have anowner_idcolumn. Even if authentication were added, there would be no way to scope views to a specific user.Current schema (from database.py):
Proposed Fix
Step 1: Add
owner_idColumn (Database Migration)Create a new migration file:
Step 2: Add Authentication to the Router
Step 3: Scope All Queries to Authenticated User
Similarly scope
PUTandDELETEto the authenticated owner's views.Step 4: Backfill Migration for Existing Deployments
For existing deployments, if the database currently has views from multiple users that cannot be attributed, either: