Files
HarborForge.Backend/app/api/routers/webhooks.py
hzhang 801a63f8bb 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>
2026-05-16 16:53:14 +01:00

81 lines
3.2 KiB
Python

"""Webhooks router."""
import json
from typing import List
from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks
from sqlalchemy.orm import Session
from app.core.config import get_db
from app.api.deps import require_admin
from app.models.webhook import Webhook, WebhookLog
from app.schemas.webhook import WebhookCreate, WebhookUpdate, WebhookResponse, WebhookLogResponse
from app.services.webhook import fire_webhooks_sync
# 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)
def create_webhook(wh: WebhookCreate, db: Session = Depends(get_db)):
db_wh = Webhook(**wh.model_dump())
db.add(db_wh)
db.commit()
db.refresh(db_wh)
return db_wh
@router.get("", response_model=List[WebhookResponse])
def list_webhooks(project_id: int = None, db: Session = Depends(get_db)):
query = db.query(Webhook)
if project_id is not None:
query = query.filter(Webhook.project_id == project_id)
return query.all()
@router.get("/{webhook_id}", response_model=WebhookResponse)
def get_webhook(webhook_id: int, db: Session = Depends(get_db)):
wh = db.query(Webhook).filter(Webhook.id == webhook_id).first()
if not wh:
raise HTTPException(status_code=404, detail="Webhook not found")
return wh
@router.patch("/{webhook_id}", response_model=WebhookResponse)
def update_webhook(webhook_id: int, wh_update: WebhookUpdate, db: Session = Depends(get_db)):
wh = db.query(Webhook).filter(Webhook.id == webhook_id).first()
if not wh:
raise HTTPException(status_code=404, detail="Webhook not found")
for field, value in wh_update.model_dump(exclude_unset=True).items():
setattr(wh, field, value)
db.commit()
db.refresh(wh)
return wh
@router.delete("/{webhook_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_webhook(webhook_id: int, db: Session = Depends(get_db)):
wh = db.query(Webhook).filter(Webhook.id == webhook_id).first()
if not wh:
raise HTTPException(status_code=404, detail="Webhook not found")
db.delete(wh)
db.commit()
return None
@router.get("/{webhook_id}/logs", response_model=List[WebhookLogResponse])
def list_webhook_logs(webhook_id: int, limit: int = 50, db: Session = Depends(get_db)):
return db.query(WebhookLog).filter(
WebhookLog.webhook_id == webhook_id
).order_by(WebhookLog.created_at.desc()).limit(limit).all()
@router.post("/{webhook_id}/retry/{log_id}")
def retry_webhook(webhook_id: int, log_id: int, bg: BackgroundTasks, db: Session = Depends(get_db)):
log_entry = db.query(WebhookLog).filter(WebhookLog.id == log_id, WebhookLog.webhook_id == webhook_id).first()
if not log_entry:
raise HTTPException(status_code=404, detail="Webhook log not found")
wh = db.query(Webhook).filter(Webhook.id == webhook_id).first()
if not wh:
raise HTTPException(status_code=404, detail="Webhook not found")
bg.add_task(fire_webhooks_sync, log_entry.event, json.loads(log_entry.payload), wh.project_id, db)
return {"status": "retry_queued", "log_id": log_id}