feat: security hardening — SSRF guard, template sandbox timeout, webhook log prune, auth & backup polish
- Add outbound URL validation (SSRF) for webhook/Discord/Slack/ntfy/Matrix dispatch - Template renderer: input/output caps and thread-based render timeout - Webhook log filter: strip Authorization/signature/token-like headers; atomic prune - Auth/JWT/backup/config tightening; misc frontend UX fixes
This commit is contained in:
@@ -31,6 +31,23 @@ def _backup_dir():
|
||||
return app_config.data_dir / "backups"
|
||||
|
||||
|
||||
def _resolve_backup_file(filename: str):
|
||||
"""Validate filename and resolve to a path strictly inside the backup dir."""
|
||||
if not filename.startswith("backup-") or not filename.endswith(".json"):
|
||||
raise HTTPException(status_code=404, detail="Backup file not found")
|
||||
if "/" in filename or "\\" in filename or ".." in filename or "\x00" in filename:
|
||||
raise HTTPException(status_code=404, detail="Backup file not found")
|
||||
base = _backup_dir().resolve()
|
||||
candidate = (base / filename).resolve()
|
||||
try:
|
||||
candidate.relative_to(base)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=404, detail="Backup file not found")
|
||||
if not candidate.is_file():
|
||||
raise HTTPException(status_code=404, detail="Backup file not found")
|
||||
return candidate
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Export
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -194,9 +211,7 @@ async def download_backup_file(
|
||||
user: User = Depends(require_admin),
|
||||
):
|
||||
"""Download a specific backup file."""
|
||||
filepath = _backup_dir() / filename
|
||||
if not filepath.is_file() or not filename.startswith("backup-"):
|
||||
raise HTTPException(status_code=404, detail="Backup file not found")
|
||||
filepath = _resolve_backup_file(filename)
|
||||
|
||||
try:
|
||||
content = json.loads(filepath.read_text(encoding="utf-8"))
|
||||
@@ -215,9 +230,6 @@ async def delete_backup_file(
|
||||
user: User = Depends(require_admin),
|
||||
):
|
||||
"""Delete a specific backup file."""
|
||||
filepath = _backup_dir() / filename
|
||||
if not filepath.is_file() or not filename.startswith("backup-"):
|
||||
raise HTTPException(status_code=404, detail="Backup file not found")
|
||||
|
||||
filepath = _resolve_backup_file(filename)
|
||||
filepath.unlink()
|
||||
return {"deleted": filename}
|
||||
|
||||
@@ -350,12 +350,29 @@ def _verify_generic_webhook_auth(
|
||||
return False
|
||||
|
||||
|
||||
_SENSITIVE_HEADER_SUBSTR = (
|
||||
"token", "auth", "key", "secret", "signature", "password", "credential",
|
||||
"cookie", "x-api", "x-hub-signature",
|
||||
)
|
||||
|
||||
|
||||
def _is_sensitive_header(name: str) -> bool:
|
||||
n = name.lower()
|
||||
return any(s in n for s in _SENSITIVE_HEADER_SUBSTR)
|
||||
|
||||
|
||||
def _filter_headers(raw_headers: dict[str, str]) -> dict[str, str]:
|
||||
"""Keep only safe headers for logging (no Authorization)."""
|
||||
"""Keep only safe headers for logging (strip Authorization, signatures, tokens).
|
||||
|
||||
Allowlist base set of known-safe headers, accept X-* only if they do not
|
||||
match any sensitive substring (token/auth/key/secret/signature/...).
|
||||
"""
|
||||
safe: dict[str, str] = {}
|
||||
for k, v in raw_headers.items():
|
||||
kl = k.lower()
|
||||
if kl in ("content-type", "user-agent") or kl.startswith("x-"):
|
||||
if _is_sensitive_header(kl):
|
||||
continue
|
||||
if kl in ("content-type", "user-agent", "content-length", "accept") or kl.startswith("x-"):
|
||||
safe[k] = v
|
||||
return safe
|
||||
|
||||
@@ -384,26 +401,26 @@ async def _save_webhook_log(
|
||||
error_message=error_message,
|
||||
))
|
||||
await session.flush()
|
||||
count_result = await session.exec(
|
||||
select(func.count(WebhookPayloadLog.id))
|
||||
# Atomic prune: DELETE anything for this provider outside the newest
|
||||
# max_count rows. Avoids the COUNT -> SELECT -> DELETE race.
|
||||
keep_subq = (
|
||||
select(WebhookPayloadLog.id)
|
||||
.where(WebhookPayloadLog.provider_id == provider_id)
|
||||
.order_by(WebhookPayloadLog.created_at.desc(), WebhookPayloadLog.id.desc())
|
||||
.limit(max_count)
|
||||
.subquery()
|
||||
)
|
||||
await session.execute(
|
||||
sa_delete(WebhookPayloadLog)
|
||||
.where(WebhookPayloadLog.provider_id == provider_id)
|
||||
.where(~WebhookPayloadLog.id.in_(select(keep_subq.c.id)))
|
||||
)
|
||||
total = count_result.one()
|
||||
if total > max_count:
|
||||
oldest = await session.exec(
|
||||
select(WebhookPayloadLog.id)
|
||||
.where(WebhookPayloadLog.provider_id == provider_id)
|
||||
.order_by(WebhookPayloadLog.created_at.asc())
|
||||
.limit(total - max_count)
|
||||
)
|
||||
ids_to_delete = list(oldest.all())
|
||||
if ids_to_delete:
|
||||
await session.execute(
|
||||
sa_delete(WebhookPayloadLog)
|
||||
.where(WebhookPayloadLog.id.in_(ids_to_delete))
|
||||
)
|
||||
except Exception:
|
||||
_LOGGER.warning("Failed to save webhook payload log for provider %d", provider_id, exc_info=True)
|
||||
try:
|
||||
await session.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@router.post("/webhook/{token}")
|
||||
@@ -436,6 +453,8 @@ async def generic_webhook(token: str, request: Request):
|
||||
# Parse JSON payload
|
||||
try:
|
||||
payload = await request.json()
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("Payload must be a JSON object")
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
if store_payloads:
|
||||
async with AsyncSession(get_engine()) as log_session:
|
||||
|
||||
Reference in New Issue
Block a user