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:
2026-04-16 03:21:45 +03:00
parent 734e5c9340
commit f0739ca949
30 changed files with 567 additions and 105 deletions
@@ -69,8 +69,8 @@ async def setup(request: Request, body: SetupRequest, session: AsyncSession = De
await session.refresh(user)
return TokenResponse(
access_token=create_access_token(user.id, user.role),
refresh_token=create_refresh_token(user.id),
access_token=create_access_token(user.id, user.role, user.token_version),
refresh_token=create_refresh_token(user.id, user.token_version),
)
@@ -83,29 +83,33 @@ async def login(request: Request, body: LoginRequest, session: AsyncSession = De
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid username or password")
return TokenResponse(
access_token=create_access_token(user.id, user.role),
refresh_token=create_refresh_token(user.id),
access_token=create_access_token(user.id, user.role, user.token_version),
refresh_token=create_refresh_token(user.id, user.token_version),
)
@router.post("/refresh", response_model=TokenResponse)
async def refresh(body: RefreshRequest, session: AsyncSession = Depends(get_session)):
@limiter.limit("10/minute")
async def refresh(request: Request, body: RefreshRequest, session: AsyncSession = Depends(get_session)):
import jwt as pyjwt
try:
payload = decode_token(body.refresh_token)
if payload.get("type") != "refresh":
raise HTTPException(status_code=401, detail="Invalid token type")
user_id = int(payload["sub"])
token_version = int(payload.get("ver", 1))
except (pyjwt.PyJWTError, KeyError, ValueError) as exc:
raise HTTPException(status_code=401, detail="Invalid refresh token") from exc
user = await session.get(User, user_id)
if not user:
raise HTTPException(status_code=401, detail="User not found")
if token_version != user.token_version:
raise HTTPException(status_code=401, detail="Refresh token revoked")
return TokenResponse(
access_token=create_access_token(user.id, user.role),
refresh_token=create_refresh_token(user.id),
access_token=create_access_token(user.id, user.role, user.token_version),
refresh_token=create_refresh_token(user.id, user.token_version),
)
@@ -130,6 +134,7 @@ async def change_password(
if len(body.new_password) < 8:
raise HTTPException(status_code=400, detail="New password must be at least 8 characters")
user.hashed_password = _hash_password(body.new_password)
user.token_version = (user.token_version or 1) + 1
session.add(user)
await session.commit()
return {"success": True}