Merge security/critical-auth-fixes into main

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
h z
2026-05-16 17:55:59 +01:00
8 changed files with 260 additions and 113 deletions

232
README.md
View File

@@ -1,100 +1,163 @@
# HarborForge Backend # HarborForge Backend
Agent/人类协同任务管理平台 - FastAPI 后端 The core REST API for HarborForge — an Agent/人类协同任务管理平台 (Agent/Human collaborative task-management platform).
## API Endpoints (38) Part of the [HarborForge](../README.md) platform.
### Auth - **Role:** core REST API — users, projects, tasks, milestones, proposals, RBAC, webhooks, worklogs, notifications, monitor telemetry.
- `POST /auth/token` - 登录获取 JWT token - **Stack:** Python 3.11 · FastAPI · SQLAlchemy · MySQL
- `GET /auth/me` - 获取当前用户信息 - **Port:** `8000`
### Issues The service reads its database configuration from the AbstractWizard config volume (falling back to env/defaults) and authenticates requests with JWT (HS256) signed by `SECRET_KEY`.
> Issues 和 Search 列表接口返回分页格式:`{items, total, page, page_size, total_pages}` ## Run / Build
> Issues 支持排序参数:`sort_by` (created_at/priority/title/due_date/status), `sort_order` (asc/desc)
> Issues 支持额外过滤:`assignee_id`, `tag`
### Docker
> Issues 和 Search 列表接口返回分页格式: ```bash
> Issues 支持排序参数: (created_at/priority/title/due_date/status), (asc/desc) docker build -t harborforge-backend .
> Issues 支持额外过滤:, docker run -p 8000:8000 \
- `POST /issues` - 创建 issue支持 resolution 决议案类型) -e SECRET_KEY="$(openssl rand -hex 32)" \
- `GET /issues` - 列表(分页、排序、按 assignee/tag 过滤)(支持按 project/status/type 过滤) -v /path/to/config:/config \
- `GET /issues/{id}` - 详情 harborforge-backend
- `PATCH /issues/{id}` - 更新 ```
- `DELETE /issues/{id}` - 删除
- `POST /issues/{id}/transition` - 状态变更(触发 webhook
- `GET /search/issues?q=keyword` - 搜索
### Comments ### Local (uvicorn)
- `POST /comments` - 创建评论
- `GET /issues/{id}/comments` - 列表
- `PATCH /comments/{id}` - 更新
- `DELETE /comments/{id}` - 删除
### Projects ```bash
- `POST /projects` - 创建 pip install -r requirements.txt
- `GET /projects` - 列表 export SECRET_KEY="$(openssl rand -hex 32)"
- `GET /projects/{id}` - 详情 uvicorn app.main:app --host 0.0.0.0 --port 8000
- `PATCH /projects/{id}` - 更新 ```
- `DELETE /projects/{id}` - 删除
### Project Members On startup the app creates/migrates the schema, runs AbstractWizard
- `POST /projects/{id}/members` - 添加成员 initialization (admin user, default project, default roles), and starts a
- `GET /projects/{id}/members` - 列表 background monitor-polling thread.
- `DELETE /projects/{id}/members/{user_id}` - 移除
### Users ## Configuration
- `POST /users` - 注册
- `GET /users` - 列表
- `GET /users/{id}` - 详情
- `PATCH /users/{id}` - 更新
### Webhooks Environment variables (also loadable from a `.env` file):
- `POST /webhooks` - 创建
- `GET /webhooks` - 列表
- `GET /webhooks/{id}` - 详情
- `PATCH /webhooks/{id}` - 更新
- `DELETE /webhooks/{id}` - 删除
- `GET /webhooks/{id}/logs` - 投递日志
### System | Variable | Default | Description |
- `GET /health` - 健康检查 |----------|---------|-------------|
- `GET /version` - 版本信息 | `SECRET_KEY` | *(none — must be set)* | JWT signing key (HS256). The server **refuses to start** with a weak/default/short value. |
- `GET /dashboard/stats` - 统计面板 | `DATABASE_URL` | `mysql+pymysql://harborforge:harborforge_pass@mysql:3306/harborforge` | Fallback DB URL when the wizard config volume is absent. |
| `ALGORITHM` | `HS256` | JWT algorithm. |
| `ACCESS_TOKEN_EXPIRE_MINUTES` | `30` | Access-token lifetime. |
| `LOG_LEVEL` | `INFO` | Log level. |
| `CONFIG_DIR` | `/config` | AbstractWizard config volume directory. |
| `CONFIG_FILE` | `harborforge.json` | Config file name within `CONFIG_DIR`. |
### Milestones Database resolution order: **wizard config volume** (`$CONFIG_DIR/$CONFIG_FILE``database` block) → `DATABASE_URL` env → built-in default.
- `POST /milestones` - 创建里程碑
- `GET /milestones` - 列表(支持按 project/status 过滤)
- `GET /milestones/{id}` - 详情
- `PATCH /milestones/{id}` - 更新
- `DELETE /milestones/{id}` - 删除
- `GET /milestones/{id}/issues` - 里程碑下的 issue 列表
- `GET /milestones/{id}/progress` - 里程碑完成进度
### Notifications ## Security
- `GET /notifications` - 列表(支持 user_id, unread_only 过滤)
- `GET /notifications/count` - 未读通知计数
- `POST /notifications/{id}/read` - 标记已读
- `POST /notifications/read-all` - 全部标记已读
### Issue Assignment The current code enforces the following security posture. These are
- `POST /issues/{id}/assign` - 指派 issue自动发送通知 operational requirements, not optional hardening.
### Webhook Retry ### Mandatory strong `SECRET_KEY`
- `POST /webhooks/{id}/retry/{log_id}` - 重试失败的 webhook 投递
### Time Tracking (Work Logs) `app/core/config.py` validates `SECRET_KEY` at import time and **raises and
- `POST /worklogs` - 记录工时 refuses to start** if the value is empty, shorter than 32 characters, or a
- `GET /issues/{id}/worklogs` - 某 issue 的工时记录 known default/placeholder (e.g. `change-me-in-production`, `secret`,
- `GET /issues/{id}/worklogs/summary` - 某 issue 工时汇总 `changeme`). Operators **must** provide a strong random key:
- `GET /users/{id}/worklogs` - 某用户的工时记录
- `DELETE /worklogs/{id}` - 删除工时记录
- `GET /projects/{id}/worklogs/summary` - 项目工时汇总(按用户分组)
### Export ```bash
- `GET /export/issues` - 导出 issues CSV openssl rand -hex 32
- `GET /issues/overdue` - 逾期未完成的 issue ```
A weak signing key allows JWT forgery and full authentication bypass, so this
check is intentionally fatal.
### API-key management is admin-only and masked
The `/api-keys` endpoints (`POST`, `GET`, `DELETE /api-keys/{id}`) all require
a global admin (`require_admin`). Listing **never returns the full secret**
keys are masked to a short prefix/suffix (e.g. `abc123…9f`). The full key is
only returned once, on creation.
### Webhooks router is admin-only with SSRF protection
The entire `/webhooks` router is mounted with `dependencies=[Depends(require_admin)]`,
so every webhook endpoint (create/list/get/update/delete/logs/retry) requires a
global admin. Webhook delivery (`app/services/webhook.py`) validates the target
URL before sending:
- Only `http`/`https` schemes are allowed.
- The host is DNS-resolved and **every** resolved address is rejected if it is
private, loopback, link-local, multicast, reserved, or unspecified
(SSRF protection).
- HTTP redirects are disabled (`follow_redirects=False`).
### Project role hierarchy enforcement
`check_project_role` in `app/api/rbac.py` enforces a real, ordered role
hierarchy rather than a flat membership check:
```
guest(0) < viewer(1) < member(2) < dev(3) < mgr(4) < admin(5)
```
A caller below the required rank is denied with `403`, and any unknown role on
either side is denied by default. Global admins bypass project-level checks.
### Authentication on previously open endpoints
The following endpoints now require an authenticated caller
(JWT bearer token **or** `X-API-Key`) and enforce ownership/permission:
- `DELETE /milestones/{id}` — requires milestone-edit permission.
- `POST /worklogs` — worklogs are always attributed to the caller; only admins
may log time for another user.
- `DELETE /worklogs/{id}` — caller-scoped; non-admins cannot delete another
user's worklog.
- `POST /tasks/{task_code}/assign` and `POST /tasks/batch/assign`.
- `GET /activity`.
- `GET /export/tasks`.
## Authentication
- `POST /auth/token` — OAuth2 password grant; returns a JWT bearer token.
- Authenticated requests send `Authorization: Bearer <token>` **or**
`X-API-Key: <key>` (API keys map to a user and are created by admins).
- `GET /auth/me` — current user.
- `GET /auth/me/permissions`, `GET /auth/me/apikey-permissions` — permission introspection.
## Key API Areas
| Area | Prefix / Routes | Notes |
|------|-----------------|-------|
| Auth | `/auth/*` | token, current user, permission introspection |
| Users | `/users` | registration, list/detail/update (list & mutate are admin-only) |
| Projects | `/projects` | CRUD, members (`/projects/{id}/members`), worklog summary |
| Project members | `/projects/{id}/members` | add/list/remove with role |
| Milestones | `/projects/{id}/milestones`, `/milestones/{id}` | CRUD, items, progress |
| Milestone actions | preflight / freeze / start / close | lifecycle transitions |
| Tasks | `/tasks` | CRUD, transition, take, assign, batch transition/assign, tags, search |
| Comments | `/comments`, `/tasks/{id}/comments` | CRUD |
| Proposals | `/projects/{code}/proposals` | propose / accept / reject / reopen (legacy `/proposes`) |
| Essentials | proposal essentials | feature/improvement/refactor items |
| Meetings | `/meetings` | create/list/detail/update/delete/attend |
| Roles & RBAC | `/roles` | roles, permissions, role-permission assignment |
| Webhooks | `/webhooks` | **admin-only**; CRUD, logs, retry (SSRF-guarded delivery) |
| API keys | `/api-keys` | **admin-only**; create/list (masked)/revoke |
| Worklogs | `/worklogs`, `/tasks/{id}/worklogs`, `/users/{id}/worklogs` | time tracking & summaries |
| Notifications | `/notifications` | list, unread count, mark read / read-all |
| Activity | `/activity` | activity log (authenticated) |
| Export | `/export/tasks` | CSV export (authenticated) |
| Calendar | `/calendar` | scheduling / time slots |
| Monitor | `/monitor` | public overview, admin providers/servers, heartbeat telemetry |
| Dashboard | `/dashboard/stats` | aggregate statistics |
| System | `/health`, `/version`, `/config/status` | health, version, init status |
## Task Types
| Type | 用途 |
|------|------|
| issue | 普通任务 |
| story | 用户故事 |
| test | 测试用例 |
| resolution | 决议案Agent 僵局提交)|
## CLI ## CLI
@@ -102,18 +165,9 @@ The legacy Python CLI (`cli.py`) has been retired. Use the Go-based `hf` CLI ins
See [HarborForge.Cli](../HarborForge.Cli/README.md) for installation and usage. See [HarborForge.Cli](../HarborForge.Cli/README.md) for installation and usage.
## 技术栈 ## Tech Stack
- Python 3.11 + FastAPI - Python 3.11 + FastAPI
- SQLAlchemy + MySQL - SQLAlchemy + MySQL (auto schema create/migrate on startup)
- JWT (python-jose) - JWT (python-jose, HS256) + bcrypt password hashing
- Docker - Docker
## Issue Types
| Type | 用途 |
|------|------|
| task | 普通任务 |
| story | 用户故事 |
| test | 测试用例 |
| resolution | 决议案Agent 僵局提交)|

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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()

View File

@@ -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)

View File

@@ -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)

View File

@@ -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]