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:
@@ -78,6 +78,16 @@ def create_app() -> Quart:
|
||||
except Exception:
|
||||
logger.debug("Failed to log usage", exc_info=True)
|
||||
|
||||
response.headers.setdefault("X-Content-Type-Options", "nosniff")
|
||||
response.headers.setdefault("X-Frame-Options", "DENY")
|
||||
response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||
response.headers.setdefault(
|
||||
"Content-Security-Policy",
|
||||
"default-src 'self'; script-src 'self' 'unsafe-inline'; "
|
||||
"style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; "
|
||||
"connect-src 'self'; font-src 'self' data:; object-src 'none'; base-uri 'self';"
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
@app.before_serving
|
||||
|
||||
+10
-17
@@ -5,7 +5,7 @@ from quart import g, jsonify, session
|
||||
from fabledassistant.services.auth import get_user_by_id
|
||||
|
||||
|
||||
def login_required(f):
|
||||
def _check_auth(f, required_role: str | None = None):
|
||||
@functools.wraps(f)
|
||||
async def decorated(*args, **kwargs):
|
||||
user_id = session.get("user_id")
|
||||
@@ -15,27 +15,20 @@ def login_required(f):
|
||||
if not user:
|
||||
session.clear()
|
||||
return jsonify({"error": "Authentication required"}), 401
|
||||
g.user = user
|
||||
return await f(*args, **kwargs)
|
||||
return decorated
|
||||
|
||||
|
||||
def admin_required(f):
|
||||
@functools.wraps(f)
|
||||
async def decorated(*args, **kwargs):
|
||||
user_id = session.get("user_id")
|
||||
if not user_id:
|
||||
return jsonify({"error": "Authentication required"}), 401
|
||||
user = await get_user_by_id(user_id)
|
||||
if not user:
|
||||
session.clear()
|
||||
return jsonify({"error": "Authentication required"}), 401
|
||||
if user.role != "admin":
|
||||
if required_role and user.role != required_role:
|
||||
return jsonify({"error": "Admin access required"}), 403
|
||||
g.user = user
|
||||
return await f(*args, **kwargs)
|
||||
return decorated
|
||||
|
||||
|
||||
def login_required(f):
|
||||
return _check_auth(f)
|
||||
|
||||
|
||||
def admin_required(f):
|
||||
return _check_auth(f, required_role="admin")
|
||||
|
||||
|
||||
def get_current_user_id() -> int:
|
||||
return g.user.id
|
||||
|
||||
@@ -44,3 +44,4 @@ class Config:
|
||||
SMTP_FROM_NAME: str = os.environ.get("SMTP_FROM_NAME", "Fabled Assistant")
|
||||
SMTP_USE_TLS: bool = os.environ.get("SMTP_USE_TLS", "true").lower() in ("1", "true", "yes")
|
||||
BASE_URL: str = os.environ.get("BASE_URL", "http://localhost:5000").rstrip("/")
|
||||
TRUST_PROXY_HEADERS: bool = os.environ.get("TRUST_PROXY_HEADERS", "").lower() in ("1", "true", "yes")
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import asyncio
|
||||
import time
|
||||
from collections import defaultdict
|
||||
|
||||
_buckets: dict[str, list[float]] = defaultdict(list)
|
||||
_lock = asyncio.Lock()
|
||||
|
||||
|
||||
async def is_rate_limited(key: str, max_requests: int, window_seconds: int) -> bool:
|
||||
"""Returns True if request should be blocked (limit exceeded)."""
|
||||
async with _lock:
|
||||
now = time.monotonic()
|
||||
cutoff = now - window_seconds
|
||||
_buckets[key] = [t for t in _buckets[key] if t > cutoff]
|
||||
if len(_buckets[key]) >= max_requests:
|
||||
return True
|
||||
_buckets[key].append(now)
|
||||
return False
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ chat_bp = Blueprint("chat", __name__, url_prefix="/api/chat")
|
||||
@login_required
|
||||
async def list_conversations_route():
|
||||
uid = get_current_user_id()
|
||||
limit = request.args.get("limit", 50, type=int)
|
||||
limit = min(request.args.get("limit", 50, type=int), 500)
|
||||
offset = request.args.get("offset", 0, type=int)
|
||||
conversations, total = await list_conversations(uid, limit=limit, offset=offset)
|
||||
return jsonify({
|
||||
@@ -167,7 +167,10 @@ async def generation_stream_route(conv_id: int):
|
||||
|
||||
# Determine starting point from Last-Event-ID header or query param
|
||||
last_id_str = request.headers.get("Last-Event-ID") or request.args.get("last_event_id")
|
||||
last_id = int(last_id_str) if last_id_str is not None else -1
|
||||
try:
|
||||
last_id = int(last_id_str) if last_id_str is not None else -1
|
||||
except (ValueError, TypeError):
|
||||
last_id = -1
|
||||
|
||||
async def stream():
|
||||
cursor = last_id
|
||||
@@ -335,14 +338,17 @@ async def chat_status_route():
|
||||
result["ollama"] = "available"
|
||||
model_names = {m["name"] for m in tags_resp.json().get("models", [])}
|
||||
base = default_model.removesuffix(":latest")
|
||||
if default_model in model_names or f"{base}:latest" in model_names:
|
||||
if default_model in model_names or f"{base}:latest" in model_names or base in model_names:
|
||||
# Installed — now check if currently loaded in memory
|
||||
result["model"] = "cold"
|
||||
if not isinstance(ps_resp, Exception):
|
||||
ps_resp.raise_for_status()
|
||||
loaded_names = {m["name"] for m in ps_resp.json().get("models", [])}
|
||||
if default_model in loaded_names or f"{base}:latest" in loaded_names:
|
||||
result["model"] = "loaded"
|
||||
try:
|
||||
ps_resp.raise_for_status()
|
||||
loaded_names = {m["name"] for m in ps_resp.json().get("models", [])}
|
||||
if default_model in loaded_names or f"{base}:latest" in loaded_names or base in loaded_names:
|
||||
result["model"] = "loaded"
|
||||
except Exception:
|
||||
logger.debug("Ollama /api/ps check failed", exc_info=True)
|
||||
except Exception:
|
||||
logger.debug("Ollama status check failed", exc_info=True)
|
||||
return jsonify(result)
|
||||
|
||||
@@ -45,7 +45,7 @@ async def list_notes_route():
|
||||
tag = request.args.getlist("tag")
|
||||
sort = request.args.get("sort", "updated_at")
|
||||
order = request.args.get("order", "desc")
|
||||
limit = request.args.get("limit", 50, type=int)
|
||||
limit = min(request.args.get("limit", 50, type=int), 500)
|
||||
offset = request.args.get("offset", 0, type=int)
|
||||
|
||||
# Default to non-task notes only; ?is_task=true for tasks, ?all=true for everything
|
||||
@@ -74,7 +74,10 @@ async def create_note_route():
|
||||
priority = data.get("priority")
|
||||
due_date = None
|
||||
if data.get("due_date"):
|
||||
due_date = date.fromisoformat(data["due_date"])
|
||||
try:
|
||||
due_date = date.fromisoformat(data["due_date"])
|
||||
except ValueError:
|
||||
return jsonify({"error": "Invalid date format. Use YYYY-MM-DD."}), 400
|
||||
|
||||
note = await create_note(
|
||||
uid,
|
||||
@@ -178,9 +181,13 @@ async def update_note_route(note_id: int):
|
||||
fields[key] = data[key]
|
||||
|
||||
if "due_date" in data:
|
||||
fields["due_date"] = (
|
||||
date.fromisoformat(data["due_date"]) if data["due_date"] else None
|
||||
)
|
||||
if data["due_date"]:
|
||||
try:
|
||||
fields["due_date"] = date.fromisoformat(data["due_date"])
|
||||
except ValueError:
|
||||
return jsonify({"error": "Invalid date format. Use YYYY-MM-DD."}), 400
|
||||
else:
|
||||
fields["due_date"] = None
|
||||
|
||||
if "body" in fields:
|
||||
fields["tags"] = extract_tags(fields["body"])
|
||||
@@ -272,7 +279,10 @@ async def assist_stream_route():
|
||||
return jsonify({"error": "No active assist generation"}), 404
|
||||
|
||||
last_id_str = request.headers.get("Last-Event-ID") or request.args.get("last_event_id")
|
||||
last_id = int(last_id_str) if last_id_str is not None else -1
|
||||
try:
|
||||
last_id = int(last_id_str) if last_id_str is not None else -1
|
||||
except (ValueError, TypeError):
|
||||
last_id = -1
|
||||
|
||||
async def stream():
|
||||
cursor = last_id
|
||||
|
||||
@@ -26,13 +26,16 @@ async def list_tasks_route():
|
||||
priority = request.args.get("priority")
|
||||
sort = request.args.get("sort", "updated_at")
|
||||
order = request.args.get("order", "desc")
|
||||
limit = request.args.get("limit", 50, type=int)
|
||||
limit = min(request.args.get("limit", 50, type=int), 500)
|
||||
offset = request.args.get("offset", 0, type=int)
|
||||
|
||||
due_before_str = request.args.get("due_before")
|
||||
due_after_str = request.args.get("due_after")
|
||||
due_before = date.fromisoformat(due_before_str) if due_before_str else None
|
||||
due_after = date.fromisoformat(due_after_str) if due_after_str else None
|
||||
try:
|
||||
due_before = date.fromisoformat(due_before_str) if due_before_str else None
|
||||
due_after = date.fromisoformat(due_after_str) if due_after_str else None
|
||||
except ValueError:
|
||||
return jsonify({"error": "Invalid date format. Use YYYY-MM-DD."}), 400
|
||||
|
||||
tasks, total = await list_notes(
|
||||
uid,
|
||||
@@ -61,7 +64,10 @@ async def create_task_route():
|
||||
|
||||
due_date = None
|
||||
if data.get("due_date"):
|
||||
due_date = date.fromisoformat(data["due_date"])
|
||||
try:
|
||||
due_date = date.fromisoformat(data["due_date"])
|
||||
except ValueError:
|
||||
return jsonify({"error": "Invalid date format. Use YYYY-MM-DD."}), 400
|
||||
|
||||
status = TaskStatus(data["status"]).value if "status" in data else TaskStatus.todo.value
|
||||
priority = (
|
||||
@@ -107,9 +113,13 @@ async def update_task_route(task_id: int):
|
||||
fields["body"] = data["description"]
|
||||
|
||||
if "due_date" in data:
|
||||
fields["due_date"] = (
|
||||
date.fromisoformat(data["due_date"]) if data["due_date"] else None
|
||||
)
|
||||
if data["due_date"]:
|
||||
try:
|
||||
fields["due_date"] = date.fromisoformat(data["due_date"])
|
||||
except ValueError:
|
||||
return jsonify({"error": "Invalid date format. Use YYYY-MM-DD."}), 400
|
||||
else:
|
||||
fields["due_date"] = None
|
||||
|
||||
if "body" in fields:
|
||||
fields["tags"] = extract_tags(fields["body"])
|
||||
|
||||
@@ -24,7 +24,10 @@ def build_assist_messages(
|
||||
"your output MUST also start with a heading at the same level. "
|
||||
"You may revise the heading text but do not remove it. "
|
||||
"Do not include other sections, explanatory text, or markdown code fences around the output. "
|
||||
"Match the document's existing tone and style."
|
||||
"Match the document's existing tone and style. "
|
||||
"IMPORTANT: Preserve all markdown formatting exactly — including bullet lists (- item), "
|
||||
"numbered lists (1. item), nested/indented sub-items ( - sub), bold (**text**), "
|
||||
"italic (_text_), and code blocks. Never flatten nested lists into plain text."
|
||||
)
|
||||
|
||||
user_content = (
|
||||
|
||||
@@ -65,7 +65,7 @@ async def create_user(
|
||||
)
|
||||
await session.execute(
|
||||
update(Setting)
|
||||
.where(Setting.user_id == user.id)
|
||||
.where(Setting.user_id.is_(None))
|
||||
.values(user_id=user.id)
|
||||
)
|
||||
# Auto-close registration after first user setup
|
||||
@@ -194,8 +194,8 @@ async def create_password_reset_token(user_id: int) -> str:
|
||||
return raw_token
|
||||
|
||||
|
||||
async def reset_password_with_token(raw_token: str, new_password: str) -> bool:
|
||||
"""Validate a reset token and update the user's password. Returns True on success."""
|
||||
async def reset_password_with_token(raw_token: str, new_password: str) -> int | None:
|
||||
"""Validate a reset token and update the user's password. Returns user_id on success."""
|
||||
token_hash = hashlib.sha256(raw_token.encode()).hexdigest()
|
||||
|
||||
async with async_session() as session:
|
||||
@@ -205,22 +205,22 @@ async def reset_password_with_token(raw_token: str, new_password: str) -> bool:
|
||||
reset_token = result.scalars().first()
|
||||
|
||||
if not reset_token:
|
||||
return False
|
||||
return None
|
||||
if reset_token.used:
|
||||
return False
|
||||
return None
|
||||
if reset_token.expires_at < datetime.now(timezone.utc):
|
||||
return False
|
||||
return None
|
||||
|
||||
user = await session.get(User, reset_token.user_id)
|
||||
if not user:
|
||||
return False
|
||||
return None
|
||||
|
||||
user.password_hash = hash_password(new_password)
|
||||
reset_token.used = True
|
||||
await session.commit()
|
||||
|
||||
logger.info("Password reset via token for user %d (%s)", user.id, user.username)
|
||||
return True
|
||||
return user.id
|
||||
|
||||
|
||||
async def create_invitation(email: str, invited_by: int) -> str:
|
||||
|
||||
@@ -27,6 +27,10 @@ async def export_full_backup() -> dict:
|
||||
return {
|
||||
"version": 1,
|
||||
"scope": "full",
|
||||
"_security_notice": (
|
||||
"This backup contains hashed passwords. "
|
||||
"Store it securely and restrict access."
|
||||
),
|
||||
"users": [
|
||||
{
|
||||
"id": u.id,
|
||||
|
||||
Reference in New Issue
Block a user