fix(security): close critical auth/SSRF/RBAC holes
Verified locally end-to-end (before: exploitable, after: blocked). - config: refuse to start on weak/default/short SECRET_KEY (was trivially forgeable JWT -> full admin) - deps: add reusable require_admin dependency (JWT or API key) - api-keys: require admin to mint/list/revoke; mask key on list (was unauthenticated -> instant admin API key) - webhooks: whole router now admin-only (was fully unauthenticated CRUD + readable logs) - webhook delivery: validate URL scheme + reject hosts resolving to private/loopback/link-local/reserved IPs; disable redirects (was a readable SSRF primitive) - rbac: implement a real project-role hierarchy in check_project_role (was a no-op: any member, even guest, passed admin/mgr gates) - misc: auth on delete_milestone (+ensure_can_edit_milestone), worklog create/delete (force caller user_id, owner-only delete), /activity and /export/tasks (were unauthenticated data exposure) - tasks: auth + ensure_can_edit_task on assign_task and batch_assign Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -76,3 +76,10 @@ async def get_current_user_or_apikey(
|
|||||||
if token:
|
if token:
|
||||||
return await get_current_user(token=token, db=db)
|
return await get_current_user(token=token, db=db)
|
||||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||||
|
|
||||||
|
|
||||||
|
def require_admin(current_user: models.User = Depends(get_current_user_or_apikey)) -> models.User:
|
||||||
|
"""Dependency: caller must be a global admin (JWT or API key)."""
|
||||||
|
if not getattr(current_user, "is_admin", False):
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin privileges required")
|
||||||
|
return current_user
|
||||||
|
|||||||
@@ -81,12 +81,28 @@ def check_project_role(db: Session, user_id: int, project_id: int, min_role: str
|
|||||||
detail="Role not found"
|
detail="Role not found"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Legacy compatibility: most current routes use non-hierarchical names like dev/mgr.
|
# Enforce a real role hierarchy. Higher rank == more privilege.
|
||||||
# For now, any valid membership passes those broad checks; strict edit rules are handled
|
_RANK = {
|
||||||
# by the explicit can_edit_* helpers below.
|
"guest": 0,
|
||||||
if min_role in {"dev", "mgr", "viewer", "member", "guest", "admin"}:
|
"viewer": 1,
|
||||||
return True
|
"member": 2,
|
||||||
|
"dev": 3,
|
||||||
|
"mgr": 4,
|
||||||
|
"admin": 5,
|
||||||
|
}
|
||||||
|
role_rank = _RANK.get((role.name or "").lower())
|
||||||
|
required_rank = _RANK.get((min_role or "member").lower())
|
||||||
|
if role_rank is None or required_rank is None:
|
||||||
|
# Unknown role on either side -> deny by default.
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail=f"Insufficient project role (have '{role.name}', need '{min_role}')",
|
||||||
|
)
|
||||||
|
if role_rank < required_rank:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail=f"Insufficient project role (have '{role.name}', need '{min_role}')",
|
||||||
|
)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ from sqlalchemy import func as sqlfunc
|
|||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from app.core.config import get_db
|
from app.core.config import get_db
|
||||||
from app.api.deps import get_current_user_or_apikey
|
from app.api.deps import get_current_user_or_apikey, require_admin
|
||||||
from app.api.rbac import check_project_role, ensure_can_edit_milestone
|
from app.api.rbac import check_project_role, ensure_can_edit_milestone
|
||||||
from app.models import models
|
from app.models import models
|
||||||
from app.models.apikey import APIKey
|
from app.models.apikey import APIKey
|
||||||
@@ -60,7 +60,8 @@ class APIKeyResponse(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/api-keys", response_model=APIKeyResponse, status_code=status.HTTP_201_CREATED, tags=["API Keys"])
|
@router.post("/api-keys", response_model=APIKeyResponse, status_code=status.HTTP_201_CREATED, tags=["API Keys"])
|
||||||
def create_api_key(data: APIKeyCreate, db: Session = Depends(get_db)):
|
def create_api_key(data: APIKeyCreate, db: Session = Depends(get_db),
|
||||||
|
_: models.User = Depends(require_admin)):
|
||||||
user = db.query(models.User).filter(models.User.id == data.user_id).first()
|
user = db.query(models.User).filter(models.User.id == data.user_id).first()
|
||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
@@ -73,15 +74,22 @@ def create_api_key(data: APIKeyCreate, db: Session = Depends(get_db)):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/api-keys", response_model=List[APIKeyResponse], tags=["API Keys"])
|
@router.get("/api-keys", response_model=List[APIKeyResponse], tags=["API Keys"])
|
||||||
def list_api_keys(user_id: int = None, db: Session = Depends(get_db)):
|
def list_api_keys(user_id: int = None, db: Session = Depends(get_db),
|
||||||
|
_: models.User = Depends(require_admin)):
|
||||||
query = db.query(APIKey)
|
query = db.query(APIKey)
|
||||||
if user_id:
|
if user_id:
|
||||||
query = query.filter(APIKey.user_id == user_id)
|
query = query.filter(APIKey.user_id == user_id)
|
||||||
return query.all()
|
keys = query.all()
|
||||||
|
# Never expose the full secret on listing; show only a masked prefix.
|
||||||
|
for k in keys:
|
||||||
|
if k.key and len(k.key) > 8:
|
||||||
|
k.key = k.key[:6] + "…" + k.key[-2:]
|
||||||
|
return keys
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/api-keys/{key_id}", status_code=status.HTTP_204_NO_CONTENT, tags=["API Keys"])
|
@router.delete("/api-keys/{key_id}", status_code=status.HTTP_204_NO_CONTENT, tags=["API Keys"])
|
||||||
def revoke_api_key(key_id: int, db: Session = Depends(get_db)):
|
def revoke_api_key(key_id: int, db: Session = Depends(get_db),
|
||||||
|
_: models.User = Depends(require_admin)):
|
||||||
key_obj = db.query(APIKey).filter(APIKey.id == key_id).first()
|
key_obj = db.query(APIKey).filter(APIKey.id == key_id).first()
|
||||||
if not key_obj:
|
if not key_obj:
|
||||||
raise HTTPException(status_code=404, detail="API key not found")
|
raise HTTPException(status_code=404, detail="API key not found")
|
||||||
@@ -106,7 +114,8 @@ class ActivityLogResponse(BaseModel):
|
|||||||
|
|
||||||
@router.get("/activity", response_model=List[ActivityLogResponse], tags=["Activity"])
|
@router.get("/activity", response_model=List[ActivityLogResponse], tags=["Activity"])
|
||||||
def list_activity(entity_type: str = None, entity_id: int = None, user_id: int = None,
|
def list_activity(entity_type: str = None, entity_id: int = None, user_id: int = None,
|
||||||
limit: int = 50, db: Session = Depends(get_db)):
|
limit: int = 50, db: Session = Depends(get_db),
|
||||||
|
_: models.User = Depends(get_current_user_or_apikey)):
|
||||||
query = db.query(ActivityLog)
|
query = db.query(ActivityLog)
|
||||||
if entity_type:
|
if entity_type:
|
||||||
query = query.filter(ActivityLog.entity_type == entity_type)
|
query = query.filter(ActivityLog.entity_type == entity_type)
|
||||||
@@ -199,8 +208,10 @@ def update_milestone(milestone_id: str, ms_update: schemas.MilestoneUpdate, db:
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/milestones/{milestone_id}", status_code=status.HTTP_204_NO_CONTENT, tags=["Milestones"])
|
@router.delete("/milestones/{milestone_id}", status_code=status.HTTP_204_NO_CONTENT, tags=["Milestones"])
|
||||||
def delete_milestone(milestone_id: str, db: Session = Depends(get_db)):
|
def delete_milestone(milestone_id: str, db: Session = Depends(get_db),
|
||||||
|
current_user: models.User = Depends(get_current_user_or_apikey)):
|
||||||
ms = _resolve_milestone(db, milestone_id)
|
ms = _resolve_milestone(db, milestone_id)
|
||||||
|
ensure_can_edit_milestone(db, current_user.id, ms)
|
||||||
db.delete(ms)
|
db.delete(ms)
|
||||||
db.commit()
|
db.commit()
|
||||||
return None
|
return None
|
||||||
@@ -322,16 +333,18 @@ class WorkLogResponse(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/worklogs", response_model=WorkLogResponse, status_code=status.HTTP_201_CREATED, tags=["Time Tracking"])
|
@router.post("/worklogs", response_model=WorkLogResponse, status_code=status.HTTP_201_CREATED, tags=["Time Tracking"])
|
||||||
def create_worklog(wl: WorkLogCreate, db: Session = Depends(get_db)):
|
def create_worklog(wl: WorkLogCreate, db: Session = Depends(get_db),
|
||||||
|
current_user: models.User = Depends(get_current_user_or_apikey)):
|
||||||
task = db.query(Task).filter(Task.id == wl.task_id).first()
|
task = db.query(Task).filter(Task.id == wl.task_id).first()
|
||||||
if not task:
|
if not task:
|
||||||
raise HTTPException(status_code=404, detail="Task not found")
|
raise HTTPException(status_code=404, detail="Task not found")
|
||||||
user = db.query(models.User).filter(models.User.id == wl.user_id).first()
|
|
||||||
if not user:
|
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
|
||||||
if wl.hours <= 0:
|
if wl.hours <= 0:
|
||||||
raise HTTPException(status_code=400, detail="Hours must be positive")
|
raise HTTPException(status_code=400, detail="Hours must be positive")
|
||||||
db_wl = WorkLog(**wl.model_dump())
|
data = wl.model_dump()
|
||||||
|
# Worklogs are always attributed to the caller (non-admins cannot log time for others).
|
||||||
|
if not current_user.is_admin or not data.get("user_id"):
|
||||||
|
data["user_id"] = current_user.id
|
||||||
|
db_wl = WorkLog(**data)
|
||||||
db.add(db_wl)
|
db.add(db_wl)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(db_wl)
|
db.refresh(db_wl)
|
||||||
@@ -370,10 +383,13 @@ def task_worklog_summary(task_id: str, db: Session = Depends(get_db)):
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/worklogs/{worklog_id}", status_code=status.HTTP_204_NO_CONTENT, tags=["Time Tracking"])
|
@router.delete("/worklogs/{worklog_id}", status_code=status.HTTP_204_NO_CONTENT, tags=["Time Tracking"])
|
||||||
def delete_worklog(worklog_id: int, db: Session = Depends(get_db)):
|
def delete_worklog(worklog_id: int, db: Session = Depends(get_db),
|
||||||
|
current_user: models.User = Depends(get_current_user_or_apikey)):
|
||||||
wl = db.query(WorkLog).filter(WorkLog.id == worklog_id).first()
|
wl = db.query(WorkLog).filter(WorkLog.id == worklog_id).first()
|
||||||
if not wl:
|
if not wl:
|
||||||
raise HTTPException(status_code=404, detail="Work log not found")
|
raise HTTPException(status_code=404, detail="Work log not found")
|
||||||
|
if not current_user.is_admin and wl.user_id != current_user.id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Cannot delete another user's worklog")
|
||||||
db.delete(wl)
|
db.delete(wl)
|
||||||
db.commit()
|
db.commit()
|
||||||
return None
|
return None
|
||||||
@@ -382,7 +398,8 @@ def delete_worklog(worklog_id: int, db: Session = Depends(get_db)):
|
|||||||
# ============ Export ============
|
# ============ Export ============
|
||||||
|
|
||||||
@router.get("/export/tasks", tags=["Export"])
|
@router.get("/export/tasks", tags=["Export"])
|
||||||
def export_tasks_csv(project_id: int = None, db: Session = Depends(get_db)):
|
def export_tasks_csv(project_id: int = None, db: Session = Depends(get_db),
|
||||||
|
_: models.User = Depends(get_current_user_or_apikey)):
|
||||||
query = db.query(Task)
|
query = db.query(Task)
|
||||||
if project_id:
|
if project_id:
|
||||||
query = query.filter(Task.project_id == project_id)
|
query = query.filter(Task.project_id == project_id)
|
||||||
|
|||||||
@@ -576,8 +576,10 @@ def take_task(
|
|||||||
# ---- Assignment ----
|
# ---- Assignment ----
|
||||||
|
|
||||||
@router.post("/tasks/{task_code}/assign")
|
@router.post("/tasks/{task_code}/assign")
|
||||||
def assign_task(task_code: str, assignee_id: int, db: Session = Depends(get_db)):
|
def assign_task(task_code: str, assignee_id: int, db: Session = Depends(get_db),
|
||||||
|
current_user: models.User = Depends(get_current_user_or_apikey)):
|
||||||
task = _resolve_task(db, task_code)
|
task = _resolve_task(db, task_code)
|
||||||
|
ensure_can_edit_task(db, current_user.id, task)
|
||||||
user = db.query(models.User).filter(models.User.id == assignee_id).first()
|
user = db.query(models.User).filter(models.User.id == assignee_id).first()
|
||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
@@ -765,7 +767,8 @@ def batch_transition(
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/tasks/batch/assign")
|
@router.post("/tasks/batch/assign")
|
||||||
def batch_assign(data: BatchAssign, db: Session = Depends(get_db)):
|
def batch_assign(data: BatchAssign, db: Session = Depends(get_db),
|
||||||
|
current_user: models.User = Depends(get_current_user_or_apikey)):
|
||||||
user = db.query(models.User).filter(models.User.id == data.assignee_id).first()
|
user = db.query(models.User).filter(models.User.id == data.assignee_id).first()
|
||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(status_code=404, detail="Assignee not found")
|
raise HTTPException(status_code=404, detail="Assignee not found")
|
||||||
@@ -773,6 +776,7 @@ def batch_assign(data: BatchAssign, db: Session = Depends(get_db)):
|
|||||||
for task_code in data.task_codes:
|
for task_code in data.task_codes:
|
||||||
task = db.query(Task).filter(Task.task_code == task_code).first()
|
task = db.query(Task).filter(Task.task_code == task_code).first()
|
||||||
if task:
|
if task:
|
||||||
|
ensure_can_edit_task(db, current_user.id, task)
|
||||||
task.assignee_id = data.assignee_id
|
task.assignee_id = data.assignee_id
|
||||||
updated.append(task.task_code)
|
updated.append(task.task_code)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|||||||
@@ -5,11 +5,13 @@ from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.config import get_db
|
from app.core.config import get_db
|
||||||
|
from app.api.deps import require_admin
|
||||||
from app.models.webhook import Webhook, WebhookLog
|
from app.models.webhook import Webhook, WebhookLog
|
||||||
from app.schemas.webhook import WebhookCreate, WebhookUpdate, WebhookResponse, WebhookLogResponse
|
from app.schemas.webhook import WebhookCreate, WebhookUpdate, WebhookResponse, WebhookLogResponse
|
||||||
from app.services.webhook import fire_webhooks_sync
|
from app.services.webhook import fire_webhooks_sync
|
||||||
|
|
||||||
router = APIRouter(prefix="/webhooks", tags=["Webhooks"])
|
# Webhook management is admin-only (registration, inspection, retry, logs).
|
||||||
|
router = APIRouter(prefix="/webhooks", tags=["Webhooks"], dependencies=[Depends(require_admin)])
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=WebhookResponse, status_code=status.HTTP_201_CREATED)
|
@router.post("", response_model=WebhookResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
|||||||
@@ -44,6 +44,22 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
|
|
||||||
|
# Fail fast on a weak/default JWT signing key (prevents token forgery).
|
||||||
|
_WEAK_SECRETS = {
|
||||||
|
"change-me-in-production",
|
||||||
|
"change_me_in_production",
|
||||||
|
"change-me-use-openssl-rand-hex-32",
|
||||||
|
"secret",
|
||||||
|
"changeme",
|
||||||
|
"",
|
||||||
|
}
|
||||||
|
if settings.SECRET_KEY in _WEAK_SECRETS or len(settings.SECRET_KEY) < 32:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Insecure SECRET_KEY: set a strong random value "
|
||||||
|
"(e.g. `openssl rand -hex 32`) via the SECRET_KEY env var. "
|
||||||
|
"Refusing to start with a default/short key."
|
||||||
|
)
|
||||||
|
|
||||||
# Resolve DB URL: wizard config volume > env > default
|
# Resolve DB URL: wizard config volume > env > default
|
||||||
_db_url = _resolve_db_url(settings.DATABASE_URL)
|
_db_url = _resolve_db_url(settings.DATABASE_URL)
|
||||||
engine = create_engine(_db_url, pool_pre_ping=True)
|
engine = create_engine(_db_url, pool_pre_ping=True)
|
||||||
|
|||||||
@@ -2,12 +2,41 @@ import json
|
|||||||
import hmac
|
import hmac
|
||||||
import hashlib
|
import hashlib
|
||||||
import logging
|
import logging
|
||||||
|
import socket
|
||||||
|
import ipaddress
|
||||||
|
from urllib.parse import urlparse
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from app.models.webhook import Webhook, WebhookLog
|
from app.models.webhook import Webhook, WebhookLog
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_webhook_url(url: str) -> None:
|
||||||
|
"""Raise ValueError if the URL would target a non-public address (SSRF guard)."""
|
||||||
|
parsed = urlparse(url)
|
||||||
|
if parsed.scheme not in ("http", "https"):
|
||||||
|
raise ValueError(f"unsupported scheme: {parsed.scheme!r}")
|
||||||
|
host = parsed.hostname
|
||||||
|
if not host:
|
||||||
|
raise ValueError("missing host")
|
||||||
|
# Resolve every address the host maps to and reject non-global ranges.
|
||||||
|
try:
|
||||||
|
infos = socket.getaddrinfo(host, parsed.port or (443 if parsed.scheme == "https" else 80))
|
||||||
|
except socket.gaierror as e:
|
||||||
|
raise ValueError(f"DNS resolution failed: {e}")
|
||||||
|
for info in infos:
|
||||||
|
ip = ipaddress.ip_address(info[4][0])
|
||||||
|
if (
|
||||||
|
ip.is_private
|
||||||
|
or ip.is_loopback
|
||||||
|
or ip.is_link_local
|
||||||
|
or ip.is_multicast
|
||||||
|
or ip.is_reserved
|
||||||
|
or ip.is_unspecified
|
||||||
|
):
|
||||||
|
raise ValueError(f"host resolves to non-public address {ip}")
|
||||||
|
|
||||||
|
|
||||||
def fire_webhooks_sync(event: str, payload: dict, project_id: int, db: Session):
|
def fire_webhooks_sync(event: str, payload: dict, project_id: int, db: Session):
|
||||||
"""Find matching webhooks and send payloads (sync version)."""
|
"""Find matching webhooks and send payloads (sync version)."""
|
||||||
import httpx
|
import httpx
|
||||||
@@ -35,6 +64,8 @@ def fire_webhooks_sync(event: str, payload: dict, project_id: int, db: Session):
|
|||||||
payload=payload_json,
|
payload=payload_json,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
|
_validate_webhook_url(wh.url)
|
||||||
|
|
||||||
headers = {"Content-Type": "application/json"}
|
headers = {"Content-Type": "application/json"}
|
||||||
if wh.secret:
|
if wh.secret:
|
||||||
sig = hmac.new(
|
sig = hmac.new(
|
||||||
@@ -42,7 +73,7 @@ def fire_webhooks_sync(event: str, payload: dict, project_id: int, db: Session):
|
|||||||
).hexdigest()
|
).hexdigest()
|
||||||
headers["X-Webhook-Signature"] = sig
|
headers["X-Webhook-Signature"] = sig
|
||||||
|
|
||||||
with httpx.Client(timeout=10.0) as client:
|
with httpx.Client(timeout=10.0, follow_redirects=False) as client:
|
||||||
resp = client.post(wh.url, content=payload_json, headers=headers)
|
resp = client.post(wh.url, content=payload_json, headers=headers)
|
||||||
log.response_status = resp.status_code
|
log.response_status = resp.status_code
|
||||||
log.response_body = resp.text[:1000]
|
log.response_body = resp.text[:1000]
|
||||||
|
|||||||
Reference in New Issue
Block a user