diff --git a/core/auth.py b/core/auth.py index 4bc9a70dd..d2b94e076 100644 --- a/core/auth.py +++ b/core/auth.py @@ -48,7 +48,7 @@ ADMIN_PRIVILEGES["allowed_models_restricted"] = False # backwards for this sentinel. ADMIN_PRIVILEGES["block_all_models"] = False -from src.constants import AUTH_FILE, PASSWORD_MIN_LENGTH +from src.constants import AUTH_FILE, PASSWORD_MIN_LENGTH, PASSWORD_MAX_LENGTH DEFAULT_AUTH_PATH = AUTH_FILE TOKEN_TTL = 60 * 60 * 24 * 7 # 7 days @@ -250,6 +250,7 @@ class AuthManager: """Return public auth policy constants for the frontend.""" return { "password_min_length": PASSWORD_MIN_LENGTH, + "password_max_length": PASSWORD_MAX_LENGTH, "reserved_usernames": sorted(RESERVED_USERNAMES), "signup_enabled": self.signup_enabled, "session_days": TOKEN_TTL // 86400, diff --git a/docs/setup.md b/docs/setup.md index 53a6fb28c..21e26b164 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -453,6 +453,7 @@ Odysseus is a self-hosted workspace with powerful local tools: shell access, fil - Do not expose it directly to the public internet without HTTPS and a trusted reverse proxy or private access layer. - Keep `.env`, `data/`, `logs/`, databases, uploads, generated media, backups, auth/session files, API keys, and model/provider tokens out of Git and private shares. They are ignored by default. - Review `data/auth.json` after first boot: disable open signup unless you intentionally want it, make only your own account admin, and keep demo/test accounts non-admin. +- Passwords must be at least 8 characters long and no more than 72 bytes (bcrypt's hard limit). Multi-byte characters count toward that byte limit. - Non-admin users do not get shell/Python/file read/write by default, and admin-only routes/tools such as MCP management, API tokens, webhooks, model/cookbook serving, backup/vault, and app settings are admin-gated. Other features are controlled by per-user privileges, so review each user's privileges before exposing a deployment. - Rotate any API keys or tokens that were ever pasted into a shared chat, demo, screenshot, or log. - If you enable API tokens or webhooks, create separate tokens per integration and delete unused ones. diff --git a/routes/auth_routes.py b/routes/auth_routes.py index 5c7a4e04a..5c596de6a 100644 --- a/routes/auth_routes.py +++ b/routes/auth_routes.py @@ -13,7 +13,8 @@ from pathlib import Path from core.atomic_io import atomic_write_json, atomic_write_text from core.auth import AuthManager, RESERVED_USERNAMES, SetAdminResult, TOKEN_TTL -from src.constants import DEEP_RESEARCH_DIR, MEMORY_FILE, PASSWORD_MIN_LENGTH, SKILLS_DIR +from src.auth_helpers import validate_password_length +from src.constants import DEEP_RESEARCH_DIR, MEMORY_FILE, SKILLS_DIR from src.rate_limiter import RateLimiter from src.settings_scrub import scrub_settings from src.settings import ( @@ -102,8 +103,7 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter: raise HTTPException(429, "Too many requests — try again later") if auth_manager.is_configured: raise HTTPException(400, "Already configured") - if len(body.password) < PASSWORD_MIN_LENGTH: - raise HTTPException(400, f"Password must be at least {PASSWORD_MIN_LENGTH} characters") + validate_password_length(body.password) if len(body.username.strip()) < 1: raise HTTPException(400, "Username is required") if body.username.lower() in RESERVED_USERNAMES: @@ -122,8 +122,7 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter: raise HTTPException(400, "Run setup first") if not auth_manager.signup_enabled: raise HTTPException(403, "Registration is disabled. Ask an admin for an account.") - if len(body.password) < PASSWORD_MIN_LENGTH: - raise HTTPException(400, f"Password must be at least {PASSWORD_MIN_LENGTH} characters") + validate_password_length(body.password) if len(body.username.strip()) < 1: raise HTTPException(400, "Username is required") if body.username.lower() in RESERVED_USERNAMES: @@ -200,8 +199,7 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter: user = _get_current_user(request) if not user: raise HTTPException(401, "Not authenticated") - if len(body.new_password) < PASSWORD_MIN_LENGTH: - raise HTTPException(400, f"Password must be at least {PASSWORD_MIN_LENGTH} characters") + validate_password_length(body.new_password) current_token = request.cookies.get(SESSION_COOKIE) ok = await asyncio.to_thread(auth_manager.change_password, user, body.current_password, body.new_password) if not ok: @@ -281,8 +279,7 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter: user = _get_current_user(request) if not user or not auth_manager.is_admin(user): raise HTTPException(403, "Admin only") - if len(body.password) < PASSWORD_MIN_LENGTH: - raise HTTPException(400, f"Password must be at least {PASSWORD_MIN_LENGTH} characters") + validate_password_length(body.password) if len(body.username.strip()) < 1: raise HTTPException(400, "Username is required") if body.username.lower() in RESERVED_USERNAMES: diff --git a/setup.py b/setup.py index 5b4eadcb5..333cecf77 100644 --- a/setup.py +++ b/setup.py @@ -16,7 +16,7 @@ sys.path.insert(0, BASE_DIR) from src.constants import ( DATA_DIR, AUTH_FILE, UPLOAD_DIR, PERSONAL_DIR, PERSONAL_UPLOADS_DIR, TTS_CACHE_DIR, GENERATED_IMAGES_DIR, DEEP_RESEARCH_DIR, CHROMA_DIR, - RAG_DIR, MEMORY_VECTORS_DIR, PASSWORD_MIN_LENGTH, + RAG_DIR, MEMORY_VECTORS_DIR, PASSWORD_MIN_LENGTH, PASSWORD_MAX_LENGTH, ) from core.auth import RESERVED_USERNAMES @@ -77,6 +77,9 @@ def _prompt_admin_credentials(): if len(password) < PASSWORD_MIN_LENGTH: print(f" Password must be at least {PASSWORD_MIN_LENGTH} characters.") continue + if len(password.encode("utf-8")) > PASSWORD_MAX_LENGTH: + print(f" Password must be {PASSWORD_MAX_LENGTH} bytes or fewer.") + continue confirm = getpass.getpass(" Confirm password: ") if password != confirm: print(" Passwords don't match. Try again.") @@ -109,6 +112,9 @@ def create_default_admin(): if len(password) < PASSWORD_MIN_LENGTH: print(f" [error] ODYSSEUS_ADMIN_PASSWORD must be at least {PASSWORD_MIN_LENGTH} characters") return "failed" + if len(password.encode("utf-8")) > PASSWORD_MAX_LENGTH: + print(f" [error] ODYSSEUS_ADMIN_PASSWORD must be {PASSWORD_MAX_LENGTH} bytes or fewer") + return "failed" elif sys.stdin.isatty() and not os.getenv("ODYSSEUS_SKIP_ADMIN_PROMPT"): # Interactive terminal — ask the user username, password = _prompt_admin_credentials() diff --git a/src/auth_helpers.py b/src/auth_helpers.py index 49f3f01be..c33fd096e 100644 --- a/src/auth_helpers.py +++ b/src/auth_helpers.py @@ -4,6 +4,16 @@ import os from typing import Optional from fastapi import Request, HTTPException +from src.constants import PASSWORD_MAX_LENGTH, PASSWORD_MIN_LENGTH + + +def validate_password_length(password: str) -> None: + """Raise HTTPException(400, ...) if password violates min/max length policy.""" + if len(password) < PASSWORD_MIN_LENGTH: + raise HTTPException(400, f"Password must be at least {PASSWORD_MIN_LENGTH} characters") + if len(password.encode("utf-8")) > PASSWORD_MAX_LENGTH: + raise HTTPException(400, f"Password must be {PASSWORD_MAX_LENGTH} bytes or fewer") + def get_current_user(request: Request) -> Optional[str]: """Get current username from request state (set by auth middleware).""" diff --git a/src/constants.py b/src/constants.py index 584494290..a5d2dadce 100644 --- a/src/constants.py +++ b/src/constants.py @@ -103,6 +103,7 @@ CLEANUP_INTERVAL_HOURS = int(os.getenv("CLEANUP_INTERVAL_HOURS", "24")) # Auth policy PASSWORD_MIN_LENGTH = 8 +PASSWORD_MAX_LENGTH = 72 # Default parameters DEFAULT_TEMPERATURE = 1.0