Security audit, bug fixes, auto-save, and writing assistant improvements

Backend security & correctness:
- Add rate_limit.py: sliding-window rate limiter (asyncio) applied to login,
  register, forgot/reset password endpoints (10/60s or 5/300s per IP)
- app.py: add security headers in after_request (X-Frame-Options, CSP,
  X-Content-Type-Options, Referrer-Policy) using setdefault to preserve SSE headers
- auth.py: refactor duplicate login_required/admin_required into shared _check_auth()
- config.py: add TRUST_PROXY_HEADERS for proxy-aware client IP resolution
- routes/auth.py: rate limiting, _client_ip() helper, cleaned-up reset_password route
- routes/chat.py, notes.py, tasks.py: int() DoS fix on last_event_id; limit capped
  at 500; date.fromisoformat() wrapped in try/except → 400 on invalid dates
- services/auth.py: fix Setting.user_id update bug (filter on NULL not user.id);
  reset_password_with_token returns int|None (user_id) instead of bool
- services/backup.py: add _security_notice to full backup JSON export
- services/assist.py: system prompt explicitly preserves markdown list structure
  and nested indented sub-items

Infrastructure:
- docker-compose.yml: add healthcheck on app service (/api/health, 10s interval)
- .dockerignore: prevent secrets/node_modules/__pycache__/.env.* leaking into build

Frontend bug fixes:
- TaskCard.vue, TaskViewerView.vue: fix isOverdue() timezone bug (ISO string compare)
- useAssist.ts: accept() now resets state to idle when document changed since proposal
- stores/chat.ts: fix memory leak in _pollUntilLoaded() (try/catch around fetchStatus)
- TiptapEditor.vue: selection offset uses closest-match strategy (not first-match)
- utils/markdown.ts: explicit DOMPurify config with FORCE_BODY; remove as const
  (DOMPurify expects mutable string[])

New features:
- Auto-save (5-minute interval) in NoteEditorView and TaskEditorView — only when
  editing an existing dirty record; silent on error, shows "Auto-saved" toast
- sectionParser.ts: top-level bullet/numbered list items are now individual sections
  in the AI Assist panel (previously treated as one undifferentiated block)
- editor-shared.css: extracted ~500 lines of CSS duplicated between both editors;
  includes .inline-assist-btn at global scope (required for teleported elements)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-19 20:08:44 -05:00
parent 7b92d13863
commit 667ccd70cd
24 changed files with 865 additions and 1193 deletions
+39 -36
View File
@@ -3,6 +3,8 @@ import asyncio
from quart import Blueprint, g, jsonify, request, session
from fabledassistant.auth import login_required, get_current_user_id
from fabledassistant.config import Config
from fabledassistant.rate_limit import is_rate_limited
from fabledassistant.services.auth import (
authenticate,
change_password,
@@ -28,8 +30,19 @@ from fabledassistant.services.email import get_base_url, is_smtp_configured
auth_bp = Blueprint("auth", __name__, url_prefix="/api/auth")
def _client_ip() -> str:
if Config.TRUST_PROXY_HEADERS:
for header in ("X-Forwarded-For", "X-Real-IP"):
val = request.headers.get(header, "").split(",")[0].strip()
if val:
return val
return request.remote_addr or ""
@auth_bp.route("/register", methods=["POST"])
async def register():
if await is_rate_limited(f"register:{_client_ip()}", max_requests=5, window_seconds=300):
return jsonify({"error": "Too many registration attempts. Try again later."}), 429
if not await is_registration_open():
return jsonify({"error": "Registration is closed"}), 403
@@ -49,12 +62,14 @@ async def register():
return jsonify({"error": "Username already taken"}), 409
session["user_id"] = user.id
await log_audit("register", user_id=user.id, username=user.username, ip_address=request.remote_addr)
await log_audit("register", user_id=user.id, username=user.username, ip_address=_client_ip())
return jsonify(user.to_dict()), 201
@auth_bp.route("/login", methods=["POST"])
async def login():
if await is_rate_limited(f"login:{_client_ip()}", max_requests=10, window_seconds=60):
return jsonify({"error": "Too many login attempts. Try again later."}), 429
data = await request.get_json()
username = (data.get("username") or "").strip()
password = data.get("password") or ""
@@ -64,21 +79,21 @@ async def login():
user = await authenticate(username, password)
if not user:
await log_audit("login_failed", username=username, ip_address=request.remote_addr)
await log_audit("login_failed", username=username, ip_address=_client_ip())
# Try to notify the actual user about the failed attempt
target_user = await get_user_by_username(username)
if target_user:
asyncio.create_task(notify_security_event(
target_user.id, "login_failed",
{"ip_address": request.remote_addr, "username": username},
{"ip_address": _client_ip(), "username": username},
))
return jsonify({"error": "Invalid username or password"}), 401
session["user_id"] = user.id
await log_audit("login", user_id=user.id, username=user.username, ip_address=request.remote_addr)
await log_audit("login", user_id=user.id, username=user.username, ip_address=_client_ip())
asyncio.create_task(notify_security_event(
user.id, "login",
{"ip_address": request.remote_addr},
{"ip_address": _client_ip()},
))
return jsonify(user.to_dict())
@@ -88,10 +103,10 @@ async def logout():
uid = session.get("user_id")
if uid:
user = await get_user_by_id(uid)
await log_audit("logout", user_id=uid, username=user.username if user else None, ip_address=request.remote_addr)
await log_audit("logout", user_id=uid, username=user.username if user else None, ip_address=_client_ip())
asyncio.create_task(notify_security_event(
uid, "logout",
{"ip_address": request.remote_addr},
{"ip_address": _client_ip()},
))
session.clear()
return jsonify({"status": "ok"})
@@ -123,16 +138,18 @@ async def update_password():
if not success:
return jsonify({"error": "Current password is incorrect"}), 403
await log_audit("password_change", user_id=uid, username=g.user.username, ip_address=request.remote_addr)
await log_audit("password_change", user_id=uid, username=g.user.username, ip_address=_client_ip())
asyncio.create_task(notify_security_event(
uid, "password_change",
{"ip_address": request.remote_addr},
{"ip_address": _client_ip()},
))
return jsonify({"status": "ok"})
@auth_bp.route("/forgot-password", methods=["POST"])
async def forgot_password():
if await is_rate_limited(f"forgot:{_client_ip()}", max_requests=5, window_seconds=300):
return jsonify({"status": "ok"})
data = await request.get_json()
email = (data.get("email") or "").strip().lower()
@@ -150,7 +167,7 @@ async def forgot_password():
"password_reset_requested",
user_id=user.id,
username=user.username,
ip_address=request.remote_addr,
ip_address=_client_ip(),
)
return jsonify({"status": "ok"})
@@ -158,6 +175,8 @@ async def forgot_password():
@auth_bp.route("/reset-password", methods=["POST"])
async def reset_password():
if await is_rate_limited(f"reset:{_client_ip()}", max_requests=10, window_seconds=60):
return jsonify({"error": "Too many attempts. Try again later."}), 429
data = await request.get_json()
token = (data.get("token") or "").strip()
new_password = data.get("new_password") or ""
@@ -167,34 +186,18 @@ async def reset_password():
if len(new_password) < 8:
return jsonify({"error": "Password must be at least 8 characters"}), 400
success = await reset_password_with_token(token, new_password)
if not success:
user_id = await reset_password_with_token(token, new_password)
if user_id is None:
return jsonify({"error": "Invalid or expired reset link"}), 400
# Look up user by token to send notifications (token is now used, so look up by hash)
import hashlib
from fabledassistant.models import async_session
from fabledassistant.models.password_reset import PasswordResetToken
from sqlalchemy import select
token_hash = hashlib.sha256(token.encode()).hexdigest()
async with async_session() as session:
result = await session.execute(
select(PasswordResetToken).where(PasswordResetToken.token_hash == token_hash)
user = await get_user_by_id(user_id)
if user:
await log_audit(
"password_reset_completed",
user_id=user.id, username=user.username, ip_address=_client_ip(),
)
reset_token = result.scalars().first()
if reset_token:
user = await get_user_by_id(reset_token.user_id)
if user:
await log_audit(
"password_reset_completed",
user_id=user.id,
username=user.username,
ip_address=request.remote_addr,
)
if user.email:
asyncio.create_task(send_password_reset_success_email(user.email))
if user.email:
asyncio.create_task(send_password_reset_success_email(user.email))
return jsonify({"status": "ok"})
@@ -233,7 +236,7 @@ async def register_with_invite():
"register_with_invite",
user_id=user.id,
username=user.username,
ip_address=request.remote_addr,
ip_address=_client_ip(),
)
return jsonify(user.to_dict()), 201