diff --git a/fable-mcp/fable_mcp/server.py b/fable-mcp/fable_mcp/server.py index 87ad547..2db77c0 100644 --- a/fable-mcp/fable_mcp/server.py +++ b/fable-mcp/fable_mcp/server.py @@ -298,7 +298,7 @@ async def fable_update_task( @mcp.tool() -async def fable_add_task_log(task_id: int, body: str) -> dict: +async def fable_add_task_log(task_id: int, content: str) -> dict: """Append a timestamped progress log entry to a Fable task. Use this to record work sessions, decisions, or status updates over time without @@ -306,7 +306,7 @@ async def fable_add_task_log(task_id: int, body: str) -> dict: chronologically in the task view. """ async with FableClient() as client: - return await tasks.add_task_log(client, task_id=task_id, body=body) + return await tasks.add_task_log(client, task_id=task_id, content=content) # --------------------------------------------------------------------------- diff --git a/fable-mcp/fable_mcp/tools/tasks.py b/fable-mcp/fable_mcp/tools/tasks.py index d9bb4c2..d2f6167 100644 --- a/fable-mcp/fable_mcp/tools/tasks.py +++ b/fable-mcp/fable_mcp/tools/tasks.py @@ -87,7 +87,7 @@ async def add_task_log( client: FableClient, *, task_id: int, - body: str, + content: str, ) -> dict[str, Any]: """Append a log entry to a task.""" - return await client.post(f"/api/tasks/{task_id}/logs", json={"body": body}) + return await client.post(f"/api/tasks/{task_id}/logs", json={"content": content}) diff --git a/fable-mcp/tests/test_tools_tasks.py b/fable-mcp/tests/test_tools_tasks.py index 12246d6..17976b6 100644 --- a/fable-mcp/tests/test_tools_tasks.py +++ b/fable-mcp/tests/test_tools_tasks.py @@ -46,9 +46,9 @@ async def test_update_task_patches(client): @pytest.mark.asyncio async def test_add_task_log_posts_to_logs_endpoint(client): from fable_mcp.tools.tasks import add_task_log - client.post = AsyncMock(return_value={"id": 1, "body": "progress"}) - await add_task_log(client, task_id=9, body="progress") + client.post = AsyncMock(return_value={"id": 1, "content": "progress"}) + await add_task_log(client, task_id=9, content="progress") client.post.assert_called_once() path = client.post.call_args[0][0] assert path == "/api/tasks/9/logs" - assert client.post.call_args[1]["json"]["body"] == "progress" + assert client.post.call_args[1]["json"]["content"] == "progress" diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 112d63c..8af972c 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -123,7 +123,6 @@ export interface NotificationEntry { export interface UserSearchResult { id: number username: string - email: string | null } // --- User search --- diff --git a/frontend/src/components/ShareDialog.vue b/frontend/src/components/ShareDialog.vue index b266f03..78ebf92 100644 --- a/frontend/src/components/ShareDialog.vue +++ b/frontend/src/components/ShareDialog.vue @@ -138,7 +138,6 @@ onMounted(async () => { diff --git a/frontend/src/utils/markdown.ts b/frontend/src/utils/markdown.ts index 44672d9..98ac45e 100644 --- a/frontend/src/utils/markdown.ts +++ b/frontend/src/utils/markdown.ts @@ -38,7 +38,7 @@ const headingRenderer = { marked.use({ renderer: headingRenderer }); const PURIFY_OPTS_FULL = { - ADD_ATTR: ["data-tag", "data-title", "src", "alt"], + ADD_ATTR: ["data-tag", "data-title"], FORCE_BODY: true, }; diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue index 60f338d..6ebe438 100644 --- a/frontend/src/views/SettingsView.vue +++ b/frontend/src/views/SettingsView.vue @@ -2263,7 +2263,6 @@ FABLE_API_KEY=<your-api-key> diff --git a/src/fabledassistant/app.py b/src/fabledassistant/app.py index 735bcb1..4bac5db 100644 --- a/src/fabledassistant/app.py +++ b/src/fabledassistant/app.py @@ -62,6 +62,13 @@ def create_app() -> Quart: "Set SECRET_KEY or SECRET_KEY_FILE for production use." ) + if not Config.TRUST_PROXY_HEADERS: + logger.warning( + "TRUST_PROXY_HEADERS is not set. If this instance is behind a reverse proxy " + "(nginx, Caddy, Traefik) set TRUST_PROXY_HEADERS=true so rate limiting uses " + "real client IPs rather than the proxy IP." + ) + app.register_blueprint(admin_bp) app.register_blueprint(api) app.register_blueprint(auth_bp) diff --git a/src/fabledassistant/config.py b/src/fabledassistant/config.py index a898db7..f8b08b4 100644 --- a/src/fabledassistant/config.py +++ b/src/fabledassistant/config.py @@ -88,5 +88,10 @@ class Config: errors.append(f"SMTP_PORT={cls.SMTP_PORT} must be between 1 and 65535") if cls.oidc_enabled() and not cls.BASE_URL.startswith(("http://", "https://")): errors.append(f"BASE_URL='{cls.BASE_URL}' must start with http:// or https:// when OIDC is enabled") + if cls.SECRET_KEY == "dev-secret-change-me" and cls.SECURE_COOKIES: + errors.append( + "SECRET_KEY is set to the insecure default but SECURE_COOKIES=true indicates " + "a production deployment. Set SECRET_KEY or SECRET_KEY_FILE before starting." + ) if errors: raise ValueError("Configuration errors:\n" + "\n".join(f" - {e}" for e in errors)) diff --git a/src/fabledassistant/rate_limit.py b/src/fabledassistant/rate_limit.py index a9c9b71..91e0c62 100644 --- a/src/fabledassistant/rate_limit.py +++ b/src/fabledassistant/rate_limit.py @@ -1,3 +1,12 @@ +"""In-process sliding-window rate limiter. + +IMPORTANT — deployment note: + Rate limit counters are stored in memory and are lost on process restart. + When deployed behind a reverse proxy (nginx, Caddy, Traefik) you MUST set + TRUST_PROXY_HEADERS=true so that the real client IP is used as the bucket key + rather than the proxy's IP (which would cause all users to share one bucket). +""" + import asyncio import time from collections import defaultdict diff --git a/src/fabledassistant/routes/auth.py b/src/fabledassistant/routes/auth.py index 8d4c040..061eeee 100644 --- a/src/fabledassistant/routes/auth.py +++ b/src/fabledassistant/routes/auth.py @@ -359,7 +359,10 @@ async def oauth_callback(): return redirect("/login?error=oauth") sub = claims.get("sub", "") - email = claims.get("email", "") + # Only trust the email claim for account linking if the provider has verified it. + # An unverified email could be used to hijack an existing local account. + email_verified = claims.get("email_verified", False) + email = claims.get("email", "") if email_verified else "" preferred_username = claims.get("preferred_username", "") if not sub: diff --git a/src/fabledassistant/routes/briefing.py b/src/fabledassistant/routes/briefing.py index f6f0bca..5df7f8c 100644 --- a/src/fabledassistant/routes/briefing.py +++ b/src/fabledassistant/routes/briefing.py @@ -71,6 +71,12 @@ async def add_feed(): url = (data.get("url") or "").strip() if not url: return jsonify({"error": "url required"}), 400 + scheme = url.split("://")[0].lower() if "://" in url else "" + if scheme not in ("http", "https"): + return jsonify({"error": "Feed URL must use http or https"}), 400 + from fabledassistant.services.llm import _is_private_url + if _is_private_url(url): + return jsonify({"error": "Feed URL must not point to an internal address"}), 400 category = data.get("category") or None async with async_session() as session: diff --git a/src/fabledassistant/routes/chat.py b/src/fabledassistant/routes/chat.py index 8c69c41..7883d60 100644 --- a/src/fabledassistant/routes/chat.py +++ b/src/fabledassistant/routes/chat.py @@ -5,7 +5,7 @@ import logging import httpx from quart import Blueprint, Response, jsonify, request -from fabledassistant.auth import login_required, get_current_user_id +from fabledassistant.auth import admin_required, login_required, get_current_user_id from fabledassistant.routes.utils import not_found, parse_pagination from fabledassistant.config import Config from fabledassistant.services.chat import ( @@ -439,7 +439,7 @@ async def list_models_route(): @chat_bp.route("/models/pull", methods=["POST"]) -@login_required +@admin_required async def pull_model_route(): """Pull a model from Ollama, streaming progress via SSE.""" data = await request.get_json() @@ -478,7 +478,7 @@ async def pull_model_route(): @chat_bp.route("/models/delete", methods=["POST"]) -@login_required +@admin_required async def delete_model_route(): """Delete a model from Ollama.""" data = await request.get_json() diff --git a/src/fabledassistant/routes/images.py b/src/fabledassistant/routes/images.py index a4aec81..3519d28 100644 --- a/src/fabledassistant/routes/images.py +++ b/src/fabledassistant/routes/images.py @@ -1,14 +1,10 @@ -"""Serve locally-cached images. - -No authentication required — image IDs are opaque and unguessable (based -on the SHA-256 of the original URL). Images are served with a 1-day -Cache-Control header so the browser doesn't re-request on every page load. -""" +"""Serve locally-cached images.""" import logging from quart import Blueprint, jsonify, send_file +from fabledassistant.auth import login_required from fabledassistant.services.images import get_image_path, get_image_record logger = logging.getLogger(__name__) @@ -17,6 +13,7 @@ images_bp = Blueprint("images", __name__, url_prefix="/api/images") @images_bp.route("/", methods=["GET"]) +@login_required async def serve_image(image_id: int): """Serve a locally-cached image by its DB ID.""" record = await get_image_record(image_id) diff --git a/src/fabledassistant/routes/milestones.py b/src/fabledassistant/routes/milestones.py index 565a0e8..d50ddfe 100644 --- a/src/fabledassistant/routes/milestones.py +++ b/src/fabledassistant/routes/milestones.py @@ -50,12 +50,16 @@ async def create_milestone_route(project_id: int): data = await request.get_json() if not data.get("title"): return jsonify({"error": "title is required"}), 400 + status = data.get("status", "active") + if status not in ("active", "done"): + return jsonify({"error": "status must be 'active' or 'done'"}), 400 milestone = await create_milestone( uid, project_id, title=data["title"], description=data.get("description"), order_index=data.get("order_index", 0), + status=status, ) return jsonify(await _milestone_dict(milestone)), 201 diff --git a/src/fabledassistant/routes/projects.py b/src/fabledassistant/routes/projects.py index 60dd547..7bc77e1 100644 --- a/src/fabledassistant/routes/projects.py +++ b/src/fabledassistant/routes/projects.py @@ -38,12 +38,16 @@ async def create_project_route(): data = await request.get_json() if not data.get("title"): return jsonify({"error": "title is required"}), 400 + status = data.get("status", "active") + if status not in ("active", "archived"): + return jsonify({"error": "status must be 'active' or 'archived'"}), 400 project = await create_project( uid, title=data["title"], description=data.get("description", ""), goal=data.get("goal", ""), color=data.get("color"), + status=status, ) return jsonify(project.to_dict()), 201 diff --git a/src/fabledassistant/routes/settings.py b/src/fabledassistant/routes/settings.py index e51a471..1d3e429 100644 --- a/src/fabledassistant/routes/settings.py +++ b/src/fabledassistant/routes/settings.py @@ -3,7 +3,7 @@ from quart import Blueprint, jsonify, request from fabledassistant.auth import login_required, get_current_user_id from fabledassistant.config import Config from fabledassistant.services.caldav import CALDAV_SETTING_KEYS, get_caldav_config, test_connection -from fabledassistant.services.llm import get_installed_models +from fabledassistant.services.llm import get_installed_models, _is_private_url from fabledassistant.services.settings import delete_setting, get_all_settings, set_settings_batch settings_bp = Blueprint("settings", __name__, url_prefix="/api/settings") @@ -77,6 +77,16 @@ async def update_caldav(): uid = get_current_user_id() data = await request.get_json() + # Validate CalDAV URL before saving — block internal/private addresses + if "caldav_url" in data: + url = str(data.get("caldav_url") or "").strip() + if url: + parsed_scheme = url.split("://")[0].lower() if "://" in url else "" + if parsed_scheme not in ("http", "https"): + return jsonify({"error": "CalDAV URL must use http or https"}), 400 + if _is_private_url(url): + return jsonify({"error": "CalDAV URL must not point to an internal or private address"}), 400 + settings_to_save = {} for key in CALDAV_SETTING_KEYS: if key in data: diff --git a/src/fabledassistant/routes/users.py b/src/fabledassistant/routes/users.py index a7d6b13..a56f2f4 100644 --- a/src/fabledassistant/routes/users.py +++ b/src/fabledassistant/routes/users.py @@ -24,6 +24,6 @@ async def search_users(): ).limit(10) )).scalars().all() return jsonify({"users": [ - {"id": u.id, "username": u.username, "email": u.email} + {"id": u.id, "username": u.username} for u in users ]}) diff --git a/src/fabledassistant/services/images.py b/src/fabledassistant/services/images.py index 820840b..6e1c8b6 100644 --- a/src/fabledassistant/services/images.py +++ b/src/fabledassistant/services/images.py @@ -7,7 +7,9 @@ twice shares one file and one DB row. """ import hashlib +import ipaddress import logging +import socket from pathlib import Path from urllib.parse import urlparse @@ -21,6 +23,27 @@ from fabledassistant.models.image_cache import ImageCache logger = logging.getLogger(__name__) + +def _is_safe_image_url(url: str) -> bool: + """Return True only if the URL is a public http/https address (SSRF guard).""" + try: + parsed = urlparse(url) + if parsed.scheme not in ("http", "https"): + return False + host = parsed.hostname + if not host: + return False + if host.lower() in ("localhost", "::1"): + return False + addr_info = socket.getaddrinfo(host, None, proto=socket.IPPROTO_TCP) + for entry in addr_info: + ip = ipaddress.ip_address(entry[4][0]) + if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved or ip.is_multicast: + return False + return True + except Exception: + return False # Block on resolution failure + _ALLOWED_TYPES = { "image/jpeg", "image/png", @@ -59,6 +82,10 @@ async def fetch_and_store_image( Returns None if the URL is unreachable, not a valid image type, or exceeds IMAGE_MAX_BYTES. """ + if not _is_safe_image_url(url): + logger.warning("Blocked image fetch of private/internal URL: %s", url[:80]) + return None + url_hash = _url_hash(url) # Return existing record if already cached diff --git a/src/fabledassistant/services/milestones.py b/src/fabledassistant/services/milestones.py index f4d2ebf..59b469c 100644 --- a/src/fabledassistant/services/milestones.py +++ b/src/fabledassistant/services/milestones.py @@ -17,6 +17,7 @@ async def create_milestone( title: str, description: str | None = None, order_index: int = 0, + status: str = "active", ) -> Milestone: async with async_session() as session: milestone = Milestone( @@ -24,7 +25,7 @@ async def create_milestone( project_id=project_id, title=title, description=description, - status="active", + status=status, order_index=order_index, ) session.add(milestone) diff --git a/src/fabledassistant/services/projects.py b/src/fabledassistant/services/projects.py index 8bc1e2c..4e50655 100644 --- a/src/fabledassistant/services/projects.py +++ b/src/fabledassistant/services/projects.py @@ -17,6 +17,7 @@ async def create_project( description: str = "", goal: str = "", color: str | None = None, + status: str = "active", ) -> Project: async with async_session() as session: project = Project( @@ -25,7 +26,7 @@ async def create_project( description=description, goal=goal, color=color, - status="active", + status=status, ) session.add(project) await session.commit() diff --git a/src/fabledassistant/services/rss.py b/src/fabledassistant/services/rss.py index d85b510..7efa56d 100644 --- a/src/fabledassistant/services/rss.py +++ b/src/fabledassistant/services/rss.py @@ -55,6 +55,10 @@ async def fetch_and_cache_feed(feed_id: int, url: str) -> int: Fetch a feed URL, parse it, and upsert new items into rss_items. Returns the number of new items stored. """ + scheme = url.split("://")[0].lower() if "://" in url else "" + if scheme not in ("http", "https"): + logger.warning("Blocked RSS fetch with non-http(s) scheme: %s", url[:80]) + return 0 try: parsed = await _parse_feed(url) except Exception: