corvid/corvid/router.py
root 0097055c15 security: fix command injection, XSS, prefix validation, cycle detection
- mcp_server.py: use shlex.quote() on all shell-interpolated values to
  prevent command injection via ticket title/description/human_id
- corvid/router.py: add alphanumeric-only regex validation for environment
  prefix; add full ancestor cycle detection before setting parent_id;
  import re
- Add SECURITY_REVIEW.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 13:27:20 +00:00

454 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import hmac
import re
import uuid
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request
from pydantic import BaseModel
from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from .models import Environment, Ticket, TicketAttachment
VALID_STATUSES = {"open", "ongoing", "completed", "abandoned"}
VALID_TYPES = {"feature", "bug", "chore", "project"}
VALID_USERS = {"kevin", "claude"}
# ── Pydantic schemas ──────────────────────────────────────────────────────────
class EnvironmentCreate(BaseModel):
prefix: str
name: str
description: str | None = None
class EnvironmentUpdate(BaseModel):
prefix: str | None = None
name: str | None = None
description: str | None = None
class TicketCreate(BaseModel):
user: str = "kevin"
title: str
description: str | None = None
status: str = "open"
type: str = "feature"
parent_id: int | None = None
effort: int | None = None
startup_script: str | None = None
environment_id: int | None = None
class TicketUpdate(BaseModel):
user: str | None = None
title: str | None = None
description: str | None = None
status: str | None = None
type: str | None = None
parent_id: int | None = None
effort: int | None = None
startup_script: str | None = None
environment_id: int | None = None
class AttachmentCreate(BaseModel):
title: str
content: str
created_by: str = "kevin"
class AttachmentUpdate(BaseModel):
title: str | None = None
content: str | None = None
# ── Serialisers ───────────────────────────────────────────────────────────────
def _env_dict(e: Environment) -> dict:
return {
"id": e.id,
"prefix": e.prefix,
"name": e.name,
"description": e.description,
"created_at": e.created_at.isoformat() if e.created_at else None,
}
async def _ticket_dict(t: Ticket, db: AsyncSession) -> dict:
child_ids = (await db.execute(
select(Ticket.id).where(Ticket.parent_id == t.id).order_by(Ticket.id)
)).scalars().all()
return {
"id": t.id,
"human_id": t.human_id,
"guid": t.guid,
"user": t.user,
"title": t.title,
"description": t.description,
"submitted_at": t.submitted_at.isoformat() if t.submitted_at else None,
"status": t.status,
"type": t.type,
"parent_id": t.parent_id,
"child_ids": list(child_ids),
"attachment_count": len(t.attachments),
"effort": t.effort,
"startup_script": t.startup_script,
"environment_id": t.environment_id,
}
def _att_dict(a: TicketAttachment) -> dict:
return {
"id": a.id,
"ticket_id": a.ticket_id,
"title": a.title,
"content": a.content,
"created_by": a.created_by,
"created_at": a.created_at.isoformat() if a.created_at else None,
}
# ── Helpers ───────────────────────────────────────────────────────────────────
async def _get_ticket_or_404(db: AsyncSession, ticket_id: int) -> Ticket:
t = (await db.execute(select(Ticket).where(Ticket.id == ticket_id))).scalar_one_or_none()
if not t:
raise HTTPException(404, "ticket not found")
return t
async def _get_env_or_404(db: AsyncSession, env_id: int) -> Environment:
e = await db.get(Environment, env_id)
if not e:
raise HTTPException(404, "environment not found")
return e
async def _default_env_id(db: AsyncSession) -> int | None:
e = (await db.execute(select(Environment).order_by(Environment.id))).scalars().first()
return e.id if e else None
# ── Router factory ────────────────────────────────────────────────────────────
def make_router(api_key: str, get_db, require_user=None) -> APIRouter:
"""
Return a FastAPI APIRouter with all /api/environments/* and /api/tickets/* endpoints.
Args:
api_key: Value of TICKETS_API_KEY — requests presenting this in
X-Api-Key are authenticated as the service account.
get_db: FastAPI dependency yielding an AsyncSession.
require_user: Optional FastAPI dependency / callable(request) that
returns a user dict or raises. When provided, web
sessions are accepted alongside the API key. When
omitted, only API-key auth is accepted.
"""
router = APIRouter()
def _api_key_ok(x_api_key: str | None) -> bool:
if not api_key or not x_api_key:
return False
return hmac.compare_digest(x_api_key, api_key)
def require_ticket_auth(
request: Request,
x_api_key: str | None = Header(default=None),
):
if _api_key_ok(x_api_key):
return {"username": "claude", "email": "claude@internal"}
if require_user is not None:
return require_user(request)
if not api_key:
return {"username": "local", "email": "local@localhost"}
raise HTTPException(401, "unauthorized")
# ── Environment endpoints ─────────────────────────────────────────────────
@router.get("/api/environments")
async def list_environments(
db: AsyncSession = Depends(get_db),
_auth=Depends(require_ticket_auth),
):
rows = (await db.execute(select(Environment).order_by(Environment.id))).scalars().all()
return [_env_dict(e) for e in rows]
@router.post("/api/environments", status_code=201)
async def create_environment(
body: EnvironmentCreate,
db: AsyncSession = Depends(get_db),
_auth=Depends(require_ticket_auth),
):
prefix = body.prefix.strip().upper()
if not prefix or len(prefix) > 16:
raise HTTPException(400, "prefix must be 116 characters")
if not re.match(r'^[A-Z0-9_-]+$', prefix):
raise HTTPException(400, "prefix must contain only letters, digits, underscores, and hyphens")
existing = (await db.execute(
select(Environment).where(Environment.prefix == prefix)
)).scalar_one_or_none()
if existing:
raise HTTPException(409, f"prefix '{prefix}' already exists")
e = Environment(prefix=prefix, name=body.name.strip(), description=body.description)
db.add(e)
await db.commit()
await db.refresh(e)
return _env_dict(e)
@router.get("/api/environments/{env_id}")
async def get_environment(
env_id: int,
db: AsyncSession = Depends(get_db),
_auth=Depends(require_ticket_auth),
):
return _env_dict(await _get_env_or_404(db, env_id))
@router.patch("/api/environments/{env_id}")
async def update_environment(
env_id: int,
body: EnvironmentUpdate,
db: AsyncSession = Depends(get_db),
_auth=Depends(require_ticket_auth),
):
e = await _get_env_or_404(db, env_id)
if body.prefix is not None:
e.prefix = body.prefix.strip().upper()
if body.name is not None:
e.name = body.name.strip()
if body.description is not None:
e.description = body.description
await db.commit()
await db.refresh(e)
return _env_dict(e)
@router.delete("/api/environments/{env_id}")
async def delete_environment(
env_id: int,
db: AsyncSession = Depends(get_db),
_auth=Depends(require_ticket_auth),
):
e = await _get_env_or_404(db, env_id)
count = (await db.execute(
select(func.count()).select_from(Ticket).where(Ticket.environment_id == env_id)
)).scalar()
if count:
raise HTTPException(409, f"environment has {count} ticket(s); reassign before deleting")
await db.delete(e)
await db.commit()
return {"ok": True}
# ── Ticket endpoints ──────────────────────────────────────────────────────
@router.get("/api/tickets")
async def list_tickets(
status: list[str] = Query(default=[]),
type: list[str] = Query(default=[]),
q: str = Query(default=""),
environment_id: int | None = Query(default=None),
db: AsyncSession = Depends(get_db),
_auth=Depends(require_ticket_auth),
):
stmt = select(Ticket).order_by(Ticket.submitted_at.desc())
if environment_id is not None:
stmt = stmt.where(Ticket.environment_id == environment_id)
if status:
stmt = stmt.where(Ticket.status.in_(status))
if type:
stmt = stmt.where(Ticket.type.in_(type))
if q:
term = f"%{q}%"
stmt = stmt.where(or_(Ticket.title.ilike(term), Ticket.description.ilike(term)))
rows = (await db.execute(stmt)).scalars().all()
return [await _ticket_dict(t, db) for t in rows]
@router.post("/api/tickets", status_code=201)
async def create_ticket(
body: TicketCreate,
db: AsyncSession = Depends(get_db),
_auth=Depends(require_ticket_auth),
):
if body.status not in VALID_STATUSES:
raise HTTPException(400, f"status must be one of {sorted(VALID_STATUSES)}")
if body.type not in VALID_TYPES:
raise HTTPException(400, f"type must be one of {sorted(VALID_TYPES)}")
if body.user not in VALID_USERS:
raise HTTPException(400, f"user must be one of {sorted(VALID_USERS)}")
if body.effort is not None and not (1 <= body.effort <= 10):
raise HTTPException(400, "effort must be between 1 and 10")
if body.parent_id is not None:
await _get_ticket_or_404(db, body.parent_id)
env_id = body.environment_id
if env_id is not None:
await _get_env_or_404(db, env_id)
else:
env_id = await _default_env_id(db)
t = Ticket(
guid=str(uuid.uuid4()),
user=body.user,
title=body.title.strip(),
description=body.description,
status=body.status,
type=body.type,
parent_id=body.parent_id,
effort=body.effort,
startup_script=body.startup_script,
environment_id=env_id,
)
db.add(t)
await db.commit()
return await _ticket_dict(await _get_ticket_or_404(db, t.id), db)
@router.get("/api/tickets/{ticket_id}")
async def get_ticket(
ticket_id: int,
db: AsyncSession = Depends(get_db),
_auth=Depends(require_ticket_auth),
):
return await _ticket_dict(await _get_ticket_or_404(db, ticket_id), db)
@router.patch("/api/tickets/{ticket_id}")
async def update_ticket(
ticket_id: int,
body: TicketUpdate,
db: AsyncSession = Depends(get_db),
_auth=Depends(require_ticket_auth),
):
t = await _get_ticket_or_404(db, ticket_id)
if body.status is not None:
if body.status not in VALID_STATUSES:
raise HTTPException(400, f"status must be one of {sorted(VALID_STATUSES)}")
t.status = body.status
if body.type is not None:
if body.type not in VALID_TYPES:
raise HTTPException(400, f"type must be one of {sorted(VALID_TYPES)}")
t.type = body.type
if body.user is not None:
if body.user not in VALID_USERS:
raise HTTPException(400, f"user must be one of {sorted(VALID_USERS)}")
t.user = body.user
if body.title is not None:
t.title = body.title.strip()
if body.description is not None:
t.description = body.description
if "parent_id" in body.model_fields_set:
if body.parent_id is not None:
if body.parent_id == ticket_id:
raise HTTPException(400, "ticket cannot be its own parent")
await _get_ticket_or_404(db, body.parent_id)
# Detect cycles of any depth before setting parent
ancestor_id = body.parent_id
visited: set[int] = set()
while ancestor_id is not None:
if ancestor_id == ticket_id:
raise HTTPException(400, "setting this parent would create a cycle")
if ancestor_id in visited:
break
visited.add(ancestor_id)
ancestor = (await db.execute(
select(Ticket).where(Ticket.id == ancestor_id)
)).scalar_one_or_none()
ancestor_id = ancestor.parent_id if ancestor else None
t.parent_id = body.parent_id
if "effort" in body.model_fields_set:
if body.effort is not None and not (1 <= body.effort <= 10):
raise HTTPException(400, "effort must be between 1 and 10")
t.effort = body.effort
if "startup_script" in body.model_fields_set:
t.startup_script = body.startup_script
if "environment_id" in body.model_fields_set and body.environment_id is not None:
await _get_env_or_404(db, body.environment_id)
t.environment_id = body.environment_id
await db.commit()
return await _ticket_dict(await _get_ticket_or_404(db, ticket_id), db)
@router.delete("/api/tickets/{ticket_id}")
async def delete_ticket(
ticket_id: int,
db: AsyncSession = Depends(get_db),
_auth=Depends(require_ticket_auth),
):
t = await _get_ticket_or_404(db, ticket_id)
await db.delete(t)
await db.commit()
return {"ok": True}
# ── Attachment endpoints ──────────────────────────────────────────────────
@router.get("/api/tickets/{ticket_id}/attachments")
async def list_attachments(
ticket_id: int,
db: AsyncSession = Depends(get_db),
_auth=Depends(require_ticket_auth),
):
await _get_ticket_or_404(db, ticket_id)
rows = (await db.execute(
select(TicketAttachment)
.where(TicketAttachment.ticket_id == ticket_id)
.order_by(TicketAttachment.created_at)
)).scalars().all()
return [_att_dict(a) for a in rows]
@router.post("/api/tickets/{ticket_id}/attachments", status_code=201)
async def create_attachment(
ticket_id: int,
body: AttachmentCreate,
db: AsyncSession = Depends(get_db),
_auth=Depends(require_ticket_auth),
):
await _get_ticket_or_404(db, ticket_id)
if not body.title.strip():
raise HTTPException(400, "title is required")
if not body.content.strip():
raise HTTPException(400, "content is required")
if body.created_by not in VALID_USERS:
raise HTTPException(400, f"created_by must be one of {sorted(VALID_USERS)}")
a = TicketAttachment(
ticket_id=ticket_id,
title=body.title.strip(),
content=body.content,
created_by=body.created_by,
)
db.add(a)
await db.commit()
await db.refresh(a)
return _att_dict(a)
@router.patch("/api/tickets/{ticket_id}/attachments/{att_id}")
async def update_attachment(
ticket_id: int,
att_id: int,
body: AttachmentUpdate,
db: AsyncSession = Depends(get_db),
_auth=Depends(require_ticket_auth),
):
a = await db.get(TicketAttachment, att_id)
if not a or a.ticket_id != ticket_id:
raise HTTPException(404, "attachment not found")
if body.title is not None:
if not body.title.strip():
raise HTTPException(400, "title cannot be empty")
a.title = body.title.strip()
if body.content is not None:
if not body.content.strip():
raise HTTPException(400, "content cannot be empty")
a.content = body.content
await db.commit()
await db.refresh(a)
return _att_dict(a)
@router.delete("/api/tickets/{ticket_id}/attachments/{att_id}")
async def delete_attachment(
ticket_id: int,
att_id: int,
db: AsyncSession = Depends(get_db),
_auth=Depends(require_ticket_auth),
):
a = await db.get(TicketAttachment, att_id)
if not a or a.ticket_id != ticket_id:
raise HTTPException(404, "attachment not found")
await db.delete(a)
await db.commit()
return {"ok": True}
return router