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