0.2.26: security hardening from audit (P2/P3 fixes)
Address the security-auditor findings the user selected (items 2, 3, 4): Default admin credentials (P2): remove the fixed `Ten31` default. First boot now generates a strong random admin password (secrets.token_urlsafe), records it 0600 at /data/.admin-password, and surfaces it once via a new "Show Initial Admin Password" StartOS action (CLI `show-admin-password`). The stored password is cleared when the admin is reset (CLI reset-password) or self-changes it (change-password endpoint). Login hardening (P2): add a per-IP in-memory sliding-window rate limiter (10 failures / 5 min -> 429 + Retry-After) in ratelimit.py; run a dummy argon2 verify when the user is unknown so timing can't enumerate usernames; keep a single generic 401 for unknown-user and wrong-password. Hardening (P3): server process now runs unprivileged -- Dockerfile adds uid 10001 appuser; start.sh (still root) chowns the mounted /data then drops via `setpriv` before exec'ing uvicorn. Spreadsheet imports are size-capped via storage.read_capped (413 past MAX_UPLOAD_SIZE) in the schedule, capital preview, and batch paths. batch_import no longer returns raw exception text (generic per-file messages). Verified in the packed amd64 container: PID1 uvicorn runs as uid 10001, /data owned 10001 with 0600 secrets; generated admin password retrievable via CLI and logs in (200); 11th bad login -> 429; admin reset clears the stored password. Tests: test_auth_hardening.py (4). Full suite 21 passed; frontend tsc + StartOS bundle clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
025aff4fac
commit
69f12b0519
@@ -1,10 +1,12 @@
|
||||
"""CLI commands for Ten31Portal (also driven by the StartOS service Actions)."""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from ten31portal import config
|
||||
from ten31portal.auth import hash_password
|
||||
from ten31portal.database import engine
|
||||
from ten31portal.db_init import run_migrations
|
||||
@@ -56,6 +58,14 @@ def create_user(args: argparse.Namespace) -> None:
|
||||
print(f"Created user: {user.name} ({user.username}) with role {user.role.value}")
|
||||
|
||||
|
||||
def _clear_admin_password_file() -> None:
|
||||
"""Remove the recorded initial admin password (it is no longer valid once changed)."""
|
||||
try:
|
||||
os.remove(config.ADMIN_PASSWORD_FILE)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def reset_password(args: argparse.Namespace) -> None:
|
||||
"""Reset a user's password (and re-enable their login)."""
|
||||
run_migrations()
|
||||
@@ -68,11 +78,29 @@ def reset_password(args: argparse.Namespace) -> None:
|
||||
sys.exit(1)
|
||||
user.password_hash = hash_password(args.password)
|
||||
user.login_enabled = True
|
||||
is_admin = user.is_service_admin
|
||||
session.add(user)
|
||||
session.commit()
|
||||
# Resetting the built-in admin invalidates the initial password recorded on first boot.
|
||||
if is_admin:
|
||||
_clear_admin_password_file()
|
||||
print(f"Password reset for {user.name} ({user.username}).")
|
||||
|
||||
|
||||
def show_admin_password(args: argparse.Namespace) -> None:
|
||||
"""Print the randomly-generated initial admin password recorded on first boot."""
|
||||
path = config.ADMIN_PASSWORD_FILE
|
||||
if not os.path.exists(path):
|
||||
print(
|
||||
"No stored initial password. It was either set explicitly at install time, or the "
|
||||
"admin password has already been changed. Use Reset Password to set a new one."
|
||||
)
|
||||
return
|
||||
with open(path) as f:
|
||||
pw = f.read().strip()
|
||||
print(pw if pw else "(the recorded initial password is empty)")
|
||||
|
||||
|
||||
def list_users(args: argparse.Namespace) -> None:
|
||||
"""Print all user accounts."""
|
||||
run_migrations()
|
||||
@@ -240,6 +268,8 @@ def main() -> None:
|
||||
|
||||
sub.add_parser("list-users", help="List all user accounts")
|
||||
|
||||
sub.add_parser("show-admin-password", help="Show the initial admin password from first boot")
|
||||
|
||||
delete = sub.add_parser("delete-user", help="Delete a user (not the Service Admin)")
|
||||
delete.add_argument("--username", required=False, default=None)
|
||||
delete.add_argument("--email", required=False, default=None)
|
||||
@@ -261,6 +291,8 @@ def main() -> None:
|
||||
reset_password(args)
|
||||
elif args.command == "list-users":
|
||||
list_users(args)
|
||||
elif args.command == "show-admin-password":
|
||||
show_admin_password(args)
|
||||
elif args.command == "delete-user":
|
||||
delete_user(args)
|
||||
elif args.command == "dedupe-holdings":
|
||||
|
||||
Reference in New Issue
Block a user