diff --git a/.gitea/workflows/ci-cd.yml b/.gitea/workflows/ci-cd.yml index 4ae531e..fb5ed15 100644 --- a/.gitea/workflows/ci-cd.yml +++ b/.gitea/workflows/ci-cd.yml @@ -17,15 +17,15 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: python-version: "3.12" - name: Set up Node - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: "24" # Gitea cache restore/save stalls here; npm ci takes about 15 seconds. @@ -47,7 +47,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - name: Configure SSH key env: @@ -55,21 +55,20 @@ jobs: PROD_SSH_KNOWN_HOSTS: ${{ secrets.PROD_SSH_KNOWN_HOSTS }} run: | set -euo pipefail + : "${PROD_SSH_KNOWN_HOSTS:?PROD_SSH_KNOWN_HOSTS is required}" mkdir -p ~/.ssh chmod 700 ~/.ssh printf '%s' "$PROD_SSH_PRIVATE_KEY" > ~/.ssh/id_ed25519 chmod 600 ~/.ssh/id_ed25519 - if [ -n "${PROD_SSH_KNOWN_HOSTS:-}" ]; then - printf '%s\n' "$PROD_SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts - chmod 644 ~/.ssh/known_hosts - fi + printf '%s\n' "$PROD_SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts + chmod 644 ~/.ssh/known_hosts - name: Deploy to AMS-DEV01 env: DEPLOY_HOST: ${{ secrets.PROD_SSH_HOST }} DEPLOY_USER: ${{ secrets.PROD_SSH_USER }} DEPLOY_PATH: ${{ secrets.PROD_DEPLOY_PATH }} - DEPLOY_SSH_OPTS: -o StrictHostKeyChecking=accept-new + DEPLOY_SSH_OPTS: -o StrictHostKeyChecking=yes run: bash scripts/deploy_ams_dev01.sh deploy-beta: @@ -78,7 +77,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - name: Configure SSH key env: @@ -86,19 +85,18 @@ jobs: PROD_SSH_KNOWN_HOSTS: ${{ secrets.PROD_SSH_KNOWN_HOSTS }} run: | set -euo pipefail + : "${PROD_SSH_KNOWN_HOSTS:?PROD_SSH_KNOWN_HOSTS is required}" mkdir -p ~/.ssh chmod 700 ~/.ssh printf '%s' "$PROD_SSH_PRIVATE_KEY" > ~/.ssh/id_ed25519 chmod 600 ~/.ssh/id_ed25519 - if [ -n "${PROD_SSH_KNOWN_HOSTS:-}" ]; then - printf '%s\n' "$PROD_SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts - chmod 644 ~/.ssh/known_hosts - fi + printf '%s\n' "$PROD_SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts + chmod 644 ~/.ssh/known_hosts - name: Deploy beta to AMS-DEV01 env: DEPLOY_HOST: ${{ secrets.PROD_SSH_HOST }} DEPLOY_USER: ${{ secrets.PROD_SSH_USER }} PROD_DEPLOY_PATH: ${{ secrets.PROD_DEPLOY_PATH }} - DEPLOY_SSH_OPTS: -o StrictHostKeyChecking=accept-new + DEPLOY_SSH_OPTS: -o StrictHostKeyChecking=yes run: bash scripts/deploy_beta_ams_dev01.sh diff --git a/.gitignore b/.gitignore index c4c8f77..e963243 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ .env bootstrap-admin.json .venv/ +.security-test-venv*/ data/ !data/branding/ !data/branding/** diff --git a/Dockerfile b/Dockerfile index ad288e0..471a461 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM node:24-slim AS frontend-builder +FROM node:24-slim@sha256:2fe369e969550cde8e867afc3fe370b260140cab4a23d467074295b42163d553 AS frontend-builder WORKDIR /frontend @@ -13,11 +13,12 @@ COPY frontend/app ./app COPY frontend/public ./public COPY frontend/next-env.d.ts ./next-env.d.ts COPY frontend/next.config.js ./next.config.js +COPY frontend/proxy.ts ./proxy.ts COPY frontend/tsconfig.json ./tsconfig.json RUN npm run build -FROM python:3.14-slim +FROM python:3.14-slim@sha256:cad9a2c871761c413caa6fdd6441c783451e740a48aaeba60ae62a8b53525ef6 WORKDIR /app @@ -32,22 +33,31 @@ RUN apt-get update \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* +ARG MAGENT_UID=1000 +ARG MAGENT_GID=1000 +RUN groupadd --gid ${MAGENT_GID} magent \ + && useradd --uid ${MAGENT_UID} --gid magent --create-home --shell /usr/sbin/nologin magent + COPY backend/requirements.txt . RUN pip install --no-cache-dir -r requirements.txt -COPY backend/app ./app -COPY data/branding /app/data/branding +COPY --chown=magent:magent backend/app ./app +COPY --chown=magent:magent data/branding /app/data/branding -COPY --from=frontend-builder /frontend/.next /app/frontend/.next -COPY --from=frontend-builder /frontend/public /app/frontend/public -COPY --from=frontend-builder /frontend/node_modules /app/frontend/node_modules -COPY --from=frontend-builder /frontend/package.json /app/frontend/package.json -COPY --from=frontend-builder /frontend/next.config.js /app/frontend/next.config.js -COPY --from=frontend-builder /frontend/next-env.d.ts /app/frontend/next-env.d.ts -COPY --from=frontend-builder /frontend/tsconfig.json /app/frontend/tsconfig.json +COPY --chown=magent:magent --from=frontend-builder /frontend/.next /app/frontend/.next +COPY --chown=magent:magent --from=frontend-builder /frontend/public /app/frontend/public +COPY --chown=magent:magent --from=frontend-builder /frontend/node_modules /app/frontend/node_modules +COPY --chown=magent:magent --from=frontend-builder /frontend/package.json /app/frontend/package.json +COPY --chown=magent:magent --from=frontend-builder /frontend/next.config.js /app/frontend/next.config.js +COPY --chown=magent:magent --from=frontend-builder /frontend/proxy.ts /app/frontend/proxy.ts +COPY --chown=magent:magent --from=frontend-builder /frontend/next-env.d.ts /app/frontend/next-env.d.ts +COPY --chown=magent:magent --from=frontend-builder /frontend/tsconfig.json /app/frontend/tsconfig.json COPY docker/supervisord.conf /etc/supervisor/conf.d/magent.conf +RUN chown -R magent:magent /app +USER magent:magent + EXPOSE 3000 8000 CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/magent.conf"] diff --git a/README.md b/README.md index 80784f7..6e1341c 100644 --- a/README.md +++ b/README.md @@ -66,8 +66,9 @@ QBIT_URL="http://localhost:8080" QBIT_USERNAME="..." QBIT_PASSWORD="..." SQLITE_PATH="data/magent.db" -JWT_SECRET="replace-with-a-long-random-secret" -JWT_EXP_MINUTES="720" +JWT_SECRET="replace-with-at-least-32-random-characters" +SETTINGS_ENCRYPTION_KEY="replace-with-a-fernet-key" +JWT_EXP_MINUTES="120" ADMIN_USERNAME="set-a-real-admin-username" ADMIN_PASSWORD="set-a-long-unique-admin-password" ``` @@ -114,8 +115,9 @@ $env:QBIT_URL="http://localhost:8080" $env:QBIT_USERNAME="..." $env:QBIT_PASSWORD="..." $env:SQLITE_PATH="data/magent.db" -$env:JWT_SECRET="replace-with-a-long-random-secret" -$env:JWT_EXP_MINUTES="720" +$env:JWT_SECRET="replace-with-at-least-32-random-characters" +$env:SETTINGS_ENCRYPTION_KEY="replace-with-a-fernet-key" +$env:JWT_EXP_MINUTES="120" $env:ADMIN_USERNAME="set-a-real-admin-username" $env:ADMIN_PASSWORD="set-a-long-unique-admin-password" ``` @@ -161,7 +163,23 @@ Configure these Gitea Actions secrets before enabling the deploy job: - `PROD_SSH_HOST`: target host, for example `AMS-DEV01`. - `PROD_SSH_USER`: target user, for example `zak`. - `PROD_DEPLOY_PATH`: target app path, for example `/home/zak/magent`. -- `PROD_SSH_KNOWN_HOSTS`: optional pinned `known_hosts` entry for stricter host verification. +- `PROD_SSH_KNOWN_HOSTS`: required pinned `known_hosts` entry. Deployments reject unknown or changed hosts. + +## Security and data handling + +Generate independent signing and settings-encryption secrets before first startup: + +```bash +python -c "import secrets; print(secrets.token_urlsafe(48))" +python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" +``` + +- `JWT_SECRET` must contain at least 32 characters. Access sessions expire after 120 minutes by default and are revoked after logout, password, role, or blocked-state changes. +- `SETTINGS_ENCRYPTION_KEY` protects service API keys, SMTP credentials, webhooks, and private keys stored in SQLite. Keep it in `.env`, outside the database and its backups. If omitted, Magent derives a migration-compatible key from `JWT_SECRET`; a dedicated key is recommended. +- Invite secrets are stored as one-way hashes. Existing invite links continue to work after migration, but the admin UI cannot reveal an old link. Copy a link when it is created, or generate a replacement link later; replacement immediately invalidates the prior link. +- Magent encrypts sensitive settings, not the entire SQLite database. Request metadata, account records, logs, the `data/` volume, and backups should live on encrypted host storage with access restricted to the deployment account. +- `REQUESTS_CLEANUP_DAYS` controls routine request-history retention (90 days by default). Account deletion removes authentication and subscription records and anonymizes retained request and portal history. +- Production and beta cookies require HTTPS and use `SameSite=Strict`. Keep the backend port bound to loopback and publish the frontend only through the intended reverse proxy. ## History endpoints diff --git a/backend/app/auth.py b/backend/app/auth.py index 0ccb0c4..9074c25 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -159,6 +159,9 @@ def _load_current_user_from_token( raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is blocked") if _is_expired(user.get("expires_at")): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User access has expired") + token_version = payload.get("ver") + if not isinstance(token_version, int) or token_version != int(user.get("auth_version") or 1): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Session has been revoked") user = normalize_user_auth_provider(user) from .feature_access import permissions @@ -183,6 +186,7 @@ def _load_current_user_from_token( "is_expired": bool(user.get("is_expired", False)), "password_change_supported": bool(user.get("password_change_supported", False)), "password_provider": user.get("password_provider"), + "auth_version": int(user.get("auth_version") or 1), } diff --git a/backend/app/config.py b/backend/app/config.py index 82df002..d5abe99 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -24,7 +24,12 @@ class Settings(BaseSettings): default="DELETE", validation_alias=AliasChoices("SQLITE_JOURNAL_MODE") ) jwt_secret: str = Field(default="", validation_alias=AliasChoices("JWT_SECRET")) - jwt_exp_minutes: int = Field(default=720, validation_alias=AliasChoices("JWT_EXP_MINUTES")) + jwt_exp_minutes: int = Field(default=120, validation_alias=AliasChoices("JWT_EXP_MINUTES")) + jwt_issuer: str = Field(default="magent", validation_alias=AliasChoices("JWT_ISSUER")) + jwt_audience: str = Field(default="magent-web", validation_alias=AliasChoices("JWT_AUDIENCE")) + settings_encryption_key: Optional[str] = Field( + default=None, validation_alias=AliasChoices("SETTINGS_ENCRYPTION_KEY") + ) api_docs_enabled: bool = Field(default=False, validation_alias=AliasChoices("API_DOCS_ENABLED")) auth_rate_limit_window_seconds: int = Field( default=60, validation_alias=AliasChoices("AUTH_RATE_LIMIT_WINDOW_SECONDS") @@ -53,7 +58,7 @@ class Settings(BaseSettings): default=False, validation_alias=AliasChoices("AUTH_COOKIE_SECURE") ) auth_cookie_samesite: str = Field( - default="lax", validation_alias=AliasChoices("AUTH_COOKIE_SAMESITE") + default="strict", validation_alias=AliasChoices("AUTH_COOKIE_SAMESITE") ) auth_cookie_domain: Optional[str] = Field( default=None, validation_alias=AliasChoices("AUTH_COOKIE_DOMAIN") diff --git a/backend/app/db.py b/backend/app/db.py index 7983723..32e550f 100644 --- a/backend/app/db.py +++ b/backend/app/db.py @@ -1,15 +1,18 @@ import json +import hmac import os import sqlite3 import logging +from contextlib import suppress from hashlib import sha256 from datetime import datetime, timezone, timedelta -from time import perf_counter +from time import perf_counter, time as unix_time from typing import Any, Dict, Optional from .config import settings from .models import Snapshot -from .security import hash_password, verify_password +from .security import hash_password, verify_and_update_password, verify_password +from .secret_storage import decrypt_setting_value, encrypt_setting_value, is_sensitive_setting logger = logging.getLogger(__name__) @@ -30,7 +33,10 @@ def _db_path() -> str: if not os.path.isabs(path): app_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) path = os.path.join(app_root, path) - os.makedirs(os.path.dirname(path), exist_ok=True) + directory = os.path.dirname(path) + os.makedirs(directory, exist_ok=True) + with suppress(OSError): + os.chmod(directory, 0o700) return path @@ -53,12 +59,23 @@ def _apply_connection_pragmas(conn: sqlite3.Connection) -> None: logger.debug("sqlite pragma skipped: %s=%s", pragma, value, exc_info=True) +class _ClosingConnection(sqlite3.Connection): + def __exit__(self, exc_type, exc_value, traceback) -> bool: + try: + return super().__exit__(exc_type, exc_value, traceback) + finally: + self.close() + + def _connect() -> sqlite3.Connection: conn = sqlite3.connect( _db_path(), timeout=SQLITE_BUSY_TIMEOUT_MS / 1000, cached_statements=512, + factory=_ClosingConnection, ) + with suppress(OSError): + os.chmod(_db_path(), 0o600) _apply_connection_pragmas(conn) return conn @@ -185,6 +202,63 @@ def _has_secure_bootstrap_admin_credentials() -> bool: return bool(password and password != _DEFAULT_ADMIN_PASSWORD) +_INVITE_HASH_PREFIX = "sha256:" + + +def _normalize_invite_secret(value: str) -> str: + return "".join(character for character in str(value or "").strip().upper() if character.isalnum()) + + +def _hash_signup_invite_code(value: str) -> str: + normalized = _normalize_invite_secret(value) + return _INVITE_HASH_PREFIX + sha256(normalized.encode("utf-8")).hexdigest() + + +def _invite_code_hint(value: str) -> str: + normalized = _normalize_invite_secret(value) + return normalized[-4:] if normalized else "" + + +def _masked_invite_code(hint: Optional[str]) -> str: + return f"••••{str(hint or '').upper()}" if hint else "Protected invite" + + +def _protect_legacy_signup_invite_codes(conn: sqlite3.Connection) -> None: + rows = conn.execute( + "SELECT id, code, code_hint FROM signup_invites ORDER BY id" + ).fetchall() + for invite_id, stored_code, stored_hint in rows: + if not isinstance(stored_code, str) or stored_code.startswith(_INVITE_HASH_PREFIX): + continue + code_hash = _hash_signup_invite_code(stored_code) + duplicate = conn.execute( + "SELECT id FROM signup_invites WHERE code = ? AND id != ?", + (code_hash, invite_id), + ).fetchone() + if duplicate: + code_hash = _INVITE_HASH_PREFIX + sha256( + f"duplicate:{invite_id}:{stored_code}".encode("utf-8") + ).hexdigest() + conn.execute( + "UPDATE users SET invited_by_code = ? WHERE invited_by_code = ? COLLATE NOCASE", + (f"invite:{invite_id}", stored_code), + ) + conn.execute( + "UPDATE signup_invites SET code = ?, code_hint = ? WHERE id = ?", + (code_hash, stored_hint or _invite_code_hint(stored_code), invite_id), + ) + + +def _encrypt_legacy_sensitive_settings(conn: sqlite3.Connection) -> None: + rows = conn.execute("SELECT key, value FROM settings").fetchall() + for key, value in rows: + if value is None or not is_sensitive_setting(str(key)): + continue + encrypted = encrypt_setting_value(str(key), str(value)) + if encrypted != value: + conn.execute("UPDATE settings SET value = ? WHERE key = ?", (encrypted, key)) + + def init_db() -> None: with _connect() as conn: conn.execute("CREATE TABLE IF NOT EXISTS request_stage_cache (request_id INTEGER PRIMARY KEY, source_updated TEXT, ready INTEGER NOT NULL, checked_at REAL NOT NULL)") @@ -278,7 +352,8 @@ def init_db() -> None: invited_by_code TEXT, invited_at TEXT, jellyfin_password_hash TEXT, - last_jellyfin_auth_at TEXT + last_jellyfin_auth_at TEXT, + auth_version INTEGER NOT NULL DEFAULT 1 ) """ ) @@ -297,6 +372,18 @@ def init_db() -> None: ) """ ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS auth_rate_limits ( + scope TEXT NOT NULL, + key_hash TEXT NOT NULL, + occurred_at REAL NOT NULL + ) + """ + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_auth_rate_limits_lookup ON auth_rate_limits (scope, key_hash, occurred_at)" + ) conn.execute( """ CREATE TABLE IF NOT EXISTS signup_invites ( @@ -651,10 +738,20 @@ def init_db() -> None: conn.execute("ALTER TABLE users ADD COLUMN invited_at TEXT") except sqlite3.OperationalError: pass + try: + conn.execute("ALTER TABLE users ADD COLUMN auth_version INTEGER NOT NULL DEFAULT 1") + except sqlite3.OperationalError: + pass try: conn.execute("ALTER TABLE signup_invites ADD COLUMN recipient_email TEXT") except sqlite3.OperationalError: pass + try: + conn.execute("ALTER TABLE signup_invites ADD COLUMN code_hint TEXT") + except sqlite3.OperationalError: + pass + _protect_legacy_signup_invite_codes(conn) + _encrypt_legacy_sensitive_settings(conn) try: conn.execute("ALTER TABLE portal_items ADD COLUMN related_item_id INTEGER") except sqlite3.OperationalError: @@ -1142,7 +1239,7 @@ def get_user_by_username(username: str) -> Optional[Dict[str, Any]]: SELECT id, username, email, password_hash, role, auth_provider, jellyseerr_user_id, created_at, last_login_at, is_blocked, auto_search_enabled, invite_management_enabled, profile_id, expires_at, invited_by_code, invited_at, - jellyfin_password_hash, last_jellyfin_auth_at + jellyfin_password_hash, last_jellyfin_auth_at, auth_version FROM users WHERE username = ? COLLATE NOCASE ORDER BY id @@ -1171,6 +1268,7 @@ def get_user_by_username(username: str) -> Optional[Dict[str, Any]]: "is_expired": _is_datetime_in_past(row[13]), "jellyfin_password_hash": row[16], "last_jellyfin_auth_at": row[17], + "auth_version": int(row[18] or 1), } @@ -1181,7 +1279,7 @@ def get_user_by_jellyseerr_id(jellyseerr_user_id: int) -> Optional[Dict[str, Any SELECT id, username, email, password_hash, role, auth_provider, jellyseerr_user_id, created_at, last_login_at, is_blocked, auto_search_enabled, invite_management_enabled, profile_id, expires_at, invited_by_code, invited_at, - jellyfin_password_hash, last_jellyfin_auth_at + jellyfin_password_hash, last_jellyfin_auth_at, auth_version FROM users WHERE jellyseerr_user_id = ? ORDER BY id ASC @@ -1211,6 +1309,7 @@ def get_user_by_jellyseerr_id(jellyseerr_user_id: int) -> Optional[Dict[str, Any "is_expired": _is_datetime_in_past(row[13]), "jellyfin_password_hash": row[16], "last_jellyfin_auth_at": row[17], + "auth_version": int(row[18] or 1), } @@ -1221,7 +1320,7 @@ def get_user_by_id(user_id: int) -> Optional[Dict[str, Any]]: SELECT id, username, email, password_hash, role, auth_provider, jellyseerr_user_id, created_at, last_login_at, is_blocked, auto_search_enabled, invite_management_enabled, profile_id, expires_at, invited_by_code, invited_at, - jellyfin_password_hash, last_jellyfin_auth_at + jellyfin_password_hash, last_jellyfin_auth_at, auth_version FROM users WHERE id = ? """, @@ -1249,6 +1348,7 @@ def get_user_by_id(user_id: int) -> Optional[Dict[str, Any]]: "is_expired": _is_datetime_in_past(row[13]), "jellyfin_password_hash": row[16], "last_jellyfin_auth_at": row[17], + "auth_version": int(row[18] or 1), } def get_all_users() -> list[Dict[str, Any]]: @@ -1257,7 +1357,7 @@ def get_all_users() -> list[Dict[str, Any]]: """ SELECT id, username, email, role, auth_provider, jellyseerr_user_id, created_at, last_login_at, is_blocked, auto_search_enabled, invite_management_enabled, - profile_id, expires_at, invited_by_code, invited_at + profile_id, expires_at, invited_by_code, invited_at, auth_version FROM users ORDER BY username COLLATE NOCASE """ @@ -1281,6 +1381,7 @@ def get_all_users() -> list[Dict[str, Any]]: "expires_at": row[12], "invited_by_code": row[13], "invited_at": row[14], + "auth_version": int(row[15] or 1), "is_expired": _is_datetime_in_past(row[12]), } ) @@ -1375,24 +1476,181 @@ def set_user_blocked(username: str, blocked: bool) -> None: with _connect() as conn: conn.execute( """ - UPDATE users SET is_blocked = ? WHERE username = ? + UPDATE users SET is_blocked = ?, auth_version = auth_version + 1 WHERE username = ? """, (1 if blocked else 0, username), ) logger.info("user blocked state updated username=%s blocked=%s", username, blocked) -def delete_user_by_username(username: str) -> bool: +def _table_exists(conn: sqlite3.Connection, table_name: str) -> bool: + row = conn.execute( + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?", + (table_name,), + ).fetchone() + return bool(row) + + +def _redact_user_json(value: Any, identifiers: set[str]) -> Any: + if isinstance(value, dict): + return {key: _redact_user_json(item, identifiers) for key, item in value.items()} + if isinstance(value, list): + return [_redact_user_json(item, identifiers) for item in value] + if isinstance(value, str) and value.strip().casefold() in identifiers: + return "Deleted user" + return value + + +def delete_user_data_by_username(username: str) -> Dict[str, int | bool]: with _connect() as conn: - cursor = conn.execute( - """ - DELETE FROM users WHERE username = ? COLLATE NOCASE - """, + user = conn.execute( + "SELECT id, username, email FROM users WHERE username = ? COLLATE NOCASE", (username,), + ).fetchone() + if not user: + return {"deleted": False} + user_id, canonical_username, email = int(user[0]), str(user[1]), user[2] + pseudonym = f"deleted-user-{user_id}" + identifiers = {canonical_username.casefold()} + if isinstance(email, str) and email.strip(): + identifiers.add(email.strip().casefold()) + + counts: Dict[str, int | bool] = {"deleted": False} + request_rows = conn.execute( + """ + SELECT request_id, payload_json FROM requests_cache + WHERE requested_by_id = ? OR requested_by_norm = ? OR requested_by = ? COLLATE NOCASE + """, + (user_id, canonical_username.casefold(), canonical_username), + ).fetchall() + for request_id, payload_json in request_rows: + try: + payload = _redact_user_json(json.loads(payload_json), identifiers) + sanitized_payload = json.dumps(payload, separators=(",", ":")) + except (TypeError, json.JSONDecodeError): + sanitized_payload = "{}" + conn.execute( + """ + UPDATE requests_cache + SET requested_by = 'Deleted user', requested_by_norm = NULL, + requested_by_id = NULL, payload_json = ? + WHERE request_id = ? + """, + (sanitized_payload, request_id), + ) + snapshot_rows = conn.execute( + "SELECT id, payload_json FROM snapshots WHERE request_id = ?", + (str(request_id),), + ).fetchall() + for snapshot_id, snapshot_json in snapshot_rows: + try: + snapshot_payload = _redact_user_json( + json.loads(snapshot_json), identifiers + ) + sanitized_snapshot = json.dumps( + snapshot_payload, separators=(",", ":") + ) + except (TypeError, json.JSONDecodeError): + sanitized_snapshot = "{}" + conn.execute( + "UPDATE snapshots SET payload_json = ? WHERE id = ?", + (sanitized_snapshot, snapshot_id), + ) + conn.execute( + "UPDATE actions SET message = REPLACE(message, ?, 'Deleted user') WHERE request_id = ? AND message IS NOT NULL", + (canonical_username, str(request_id)), + ) + if email: + conn.execute( + "UPDATE actions SET message = REPLACE(message, ?, '[deleted email]') WHERE request_id = ? AND message IS NOT NULL", + (email, str(request_id)), + ) + counts["requests_anonymized"] = len(request_rows) + + direct_operations = ( + ("DELETE FROM user_activity WHERE username = ? COLLATE NOCASE", (canonical_username,), "activity_deleted"), + ("DELETE FROM password_reset_tokens WHERE username = ? COLLATE NOCASE", (canonical_username,), "reset_tokens_deleted"), + ("DELETE FROM user_feature_permissions WHERE user_id = ?", (user_id,), "feature_rows_deleted"), + ("DELETE FROM jellyfin_user_links WHERE local_user_id = ?", (user_id,), "identity_links_deleted"), + ("DELETE FROM user_identity_confirmations WHERE local_user_id = ?", (user_id,), "identity_confirmations_deleted"), + ("DELETE FROM user_identity_repairs WHERE local_user_id = ?", (user_id,), "identity_repairs_deleted"), + ("DELETE FROM user_duplicate_repairs WHERE kept_user_id = ?", (user_id,), "duplicate_repairs_deleted"), ) - deleted = cursor.rowcount > 0 - logger.warning("user delete username=%s deleted=%s", username, deleted) - return deleted + for sql, params, label in direct_operations: + counts[label] = int(conn.execute(sql, params).rowcount or 0) + + conn.execute( + "UPDATE signup_invites SET enabled = 0, created_by = ? WHERE created_by = ? COLLATE NOCASE", + (pseudonym, canonical_username), + ) + if email: + conn.execute( + "UPDATE signup_invites SET recipient_email = NULL WHERE recipient_email = ? COLLATE NOCASE", + (email,), + ) + conn.execute( + "UPDATE portal_items SET created_by_username = ?, created_by_id = NULL WHERE created_by_id = ? OR created_by_username = ? COLLATE NOCASE", + (pseudonym, user_id, canonical_username), + ) + conn.execute( + "UPDATE portal_items SET assignee_username = NULL WHERE assignee_username = ? COLLATE NOCASE", + (canonical_username,), + ) + conn.execute( + "UPDATE portal_comments SET author_username = ? WHERE author_username = ? COLLATE NOCASE", + (pseudonym, canonical_username), + ) + conn.execute( + "UPDATE portal_item_activity SET actor_username = ? WHERE actor_username = ? COLLATE NOCASE", + (pseudonym, canonical_username), + ) + conn.execute( + "UPDATE user_identity_confirmations SET confirmed_by = ? WHERE confirmed_by = ? COLLATE NOCASE", + (pseudonym, canonical_username), + ) + conn.execute( + "UPDATE user_identity_repairs SET repaired_by = ? WHERE repaired_by = ? COLLATE NOCASE", + (pseudonym, canonical_username), + ) + duplicate_rows = conn.execute( + "SELECT id, archive_json FROM user_duplicate_repairs" + ).fetchall() + for repair_id, archive_json in duplicate_rows: + try: + archive_payload = _redact_user_json( + json.loads(archive_json), identifiers + ) + except (TypeError, json.JSONDecodeError): + continue + conn.execute( + "UPDATE user_duplicate_repairs SET archive_json = ?, repaired_by = CASE WHEN repaired_by = ? COLLATE NOCASE THEN ? ELSE repaired_by END WHERE id = ?", + ( + json.dumps(archive_payload, separators=(",", ":")), + canonical_username, + pseudonym, + repair_id, + ), + ) + + for table in ("email_recap_subscriptions", "email_recap_deliveries", "newsletter_subscriptions", "newsletter_deliveries"): + if _table_exists(conn, table): + counts[f"{table}_deleted"] = int( + conn.execute(f"DELETE FROM {table} WHERE user_id = ?", (user_id,)).rowcount or 0 + ) + if _table_exists(conn, "newsletter_editions"): + conn.execute( + "UPDATE newsletter_editions SET created_by = ? WHERE created_by = ? COLLATE NOCASE", + (pseudonym, canonical_username), + ) + + deleted = conn.execute("DELETE FROM users WHERE id = ?", (user_id,)).rowcount > 0 + counts["deleted"] = deleted + logger.warning("user data deleted user_id=%s deleted=%s", user_id, deleted) + return counts + + +def delete_user_by_username(username: str) -> bool: + return bool(delete_user_data_by_username(username).get("deleted")) def delete_user_activity_by_username(username: str) -> int: @@ -1424,7 +1682,7 @@ def set_user_role(username: str, role: str) -> None: with _connect() as conn: conn.execute( """ - UPDATE users SET role = ? WHERE username = ? COLLATE NOCASE + UPDATE users SET role = ?, auth_version = auth_version + 1 WHERE username = ? COLLATE NOCASE """, (role, username), ) @@ -1635,29 +1893,31 @@ def delete_user_profile(profile_id: int) -> bool: def _row_to_signup_invite(row: Any) -> Dict[str, Any]: - max_uses = 1 if row[10] else row[6] - use_count = int(row[7] or 0) - expires_at = row[9] + max_uses = 1 if row[11] else row[7] + use_count = int(row[8] or 0) + expires_at = row[10] is_expired = _is_datetime_in_past(expires_at) remaining_uses = None if max_uses is None else max(int(max_uses) - use_count, 0) return { "id": row[0], - "code": row[1], - "label": row[2], - "description": row[3], - "profile_id": row[4], - "role": row[5], + "code": _masked_invite_code(row[2]), + "code_hint": row[2], + "code_available": False, + "label": row[3], + "description": row[4], + "profile_id": row[5], + "role": row[6], "max_uses": max_uses, "use_count": use_count, - "enabled": bool(row[8]), + "enabled": bool(row[9]), "expires_at": expires_at, - "recipient_email": row[10], - "created_by": row[11], - "created_at": row[12], - "updated_at": row[13], + "recipient_email": row[11], + "created_by": row[12], + "created_at": row[13], + "updated_at": row[14], "is_expired": is_expired, "remaining_uses": remaining_uses, - "is_usable": bool(row[8]) and not is_expired and (remaining_uses is None or remaining_uses > 0), + "is_usable": bool(row[9]) and not is_expired and (remaining_uses is None or remaining_uses > 0), } @@ -1665,7 +1925,7 @@ def list_signup_invites() -> list[Dict[str, Any]]: with _connect() as conn: rows = conn.execute( """ - SELECT id, code, label, description, profile_id, role, max_uses, use_count, enabled, + SELECT id, code, code_hint, label, description, profile_id, role, max_uses, use_count, enabled, expires_at, recipient_email, created_by, created_at, updated_at FROM signup_invites ORDER BY created_at DESC, id DESC @@ -1678,7 +1938,7 @@ def get_signup_invite_by_id(invite_id: int) -> Optional[Dict[str, Any]]: with _connect() as conn: row = conn.execute( """ - SELECT id, code, label, description, profile_id, role, max_uses, use_count, enabled, + SELECT id, code, code_hint, label, description, profile_id, role, max_uses, use_count, enabled, expires_at, recipient_email, created_by, created_at, updated_at FROM signup_invites WHERE id = ? @@ -1694,16 +1954,19 @@ def get_signup_invite_by_code(code: str) -> Optional[Dict[str, Any]]: with _connect() as conn: row = conn.execute( """ - SELECT id, code, label, description, profile_id, role, max_uses, use_count, enabled, + SELECT id, code, code_hint, label, description, profile_id, role, max_uses, use_count, enabled, expires_at, recipient_email, created_by, created_at, updated_at FROM signup_invites - WHERE code = ? COLLATE NOCASE + WHERE code = ? """, - (code,), + (_hash_signup_invite_code(code),), ).fetchone() if not row: return None - return _row_to_signup_invite(row) + invite = _row_to_signup_invite(row) + invite["code"] = _normalize_invite_secret(code) + invite["code_available"] = True + return invite def create_signup_invite( @@ -1719,6 +1982,9 @@ def create_signup_invite( recipient_email: Optional[str] = None, created_by: Optional[str] = None, ) -> Dict[str, Any]: + normalized_code = _normalize_invite_secret(code) + if not normalized_code: + raise ValueError("Invite code is required") if recipient_email: max_uses = 1 timestamp = datetime.now(timezone.utc).isoformat() @@ -1726,13 +1992,14 @@ def create_signup_invite( cursor = conn.execute( """ INSERT INTO signup_invites ( - code, label, description, profile_id, role, max_uses, use_count, enabled, + code, code_hint, label, description, profile_id, role, max_uses, use_count, enabled, expires_at, recipient_email, created_by, created_at, updated_at ) - VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?) """, ( - code, + _hash_signup_invite_code(normalized_code), + _invite_code_hint(normalized_code), label, description, profile_id, @@ -1748,20 +2015,21 @@ def create_signup_invite( ) invite_id = int(cursor.lastrowid) logger.info( - "signup invite created invite_id=%s code=%s role=%s profile_id=%s max_uses=%s enabled=%s expires_at=%s recipient_email=%s created_by=%s", + "signup invite created invite_id=%s role=%s profile_id=%s max_uses=%s enabled=%s expires_at=%s has_recipient=%s created_by=%s", invite_id, - code, role, profile_id, max_uses, enabled, expires_at, - recipient_email, + bool(recipient_email), created_by, ) invite = get_signup_invite_by_id(invite_id) if not invite: raise RuntimeError("Invite creation failed") + invite["code"] = normalized_code + invite["code_available"] = True return invite @@ -1784,31 +2052,68 @@ def update_signup_invite( if existing and existing.get('recipient_email') and int(existing.get('use_count') or 0) > 0 and recipient_email != existing.get('recipient_email'): raise ValueError('A used email invitation cannot be reassigned.') timestamp = datetime.now(timezone.utc).isoformat() + requested_code = str(code or "").strip() + rotate_code = bool(requested_code) and not requested_code.startswith("••••") and requested_code != "Protected invite" + with _connect() as conn: + if rotate_code: + normalized_code = _normalize_invite_secret(requested_code) + cursor = conn.execute( + """ + UPDATE signup_invites + SET code = ?, code_hint = ?, label = ?, description = ?, profile_id = ?, role = ?, + max_uses = ?, enabled = ?, expires_at = ?, recipient_email = ?, updated_at = ? + WHERE id = ? + """, + ( + _hash_signup_invite_code(normalized_code), _invite_code_hint(normalized_code), + label, description, profile_id, role, max_uses, 1 if enabled else 0, + expires_at, recipient_email, timestamp, invite_id, + ), + ) + else: + cursor = conn.execute( + """ + UPDATE signup_invites + SET label = ?, description = ?, profile_id = ?, role = ?, max_uses = ?, + enabled = ?, expires_at = ?, recipient_email = ?, updated_at = ? + WHERE id = ? + """, + ( + label, description, profile_id, role, max_uses, 1 if enabled else 0, + expires_at, recipient_email, timestamp, invite_id, + ), + ) + if cursor.rowcount <= 0: + return None + return get_signup_invite_by_id(invite_id) + + +def rotate_signup_invite_code(invite_id: int, code: str) -> Optional[Dict[str, Any]]: + normalized_code = _normalize_invite_secret(code) + if not normalized_code: + raise ValueError("Invite code is required") + timestamp = datetime.now(timezone.utc).isoformat() with _connect() as conn: cursor = conn.execute( """ UPDATE signup_invites - SET code = ?, label = ?, description = ?, profile_id = ?, role = ?, max_uses = ?, - enabled = ?, expires_at = ?, recipient_email = ?, updated_at = ? - WHERE id = ? + SET code = ?, code_hint = ?, updated_at = ? + WHERE id = ? AND enabled = 1 """, ( - code, - label, - description, - profile_id, - role, - max_uses, - 1 if enabled else 0, - expires_at, - recipient_email, + _hash_signup_invite_code(normalized_code), + _invite_code_hint(normalized_code), timestamp, invite_id, ), ) if cursor.rowcount <= 0: return None - return get_signup_invite_by_id(invite_id) + invite = get_signup_invite_by_id(invite_id) + if invite: + invite["code"] = normalized_code + invite["code_available"] = True + return invite def delete_signup_invite(invite_id: int) -> bool: @@ -1859,7 +2164,7 @@ def verify_user_password(username: str, password: str) -> Optional[Dict[str, Any SELECT id, username, password_hash, role, auth_provider, jellyseerr_user_id, created_at, last_login_at, is_blocked, auto_search_enabled, invite_management_enabled, profile_id, expires_at, invited_by_code, invited_at, - jellyfin_password_hash, last_jellyfin_auth_at + jellyfin_password_hash, last_jellyfin_auth_at, auth_version FROM users WHERE username = ? COLLATE NOCASE ORDER BY @@ -1874,8 +2179,15 @@ def verify_user_password(username: str, password: str) -> Optional[Dict[str, Any provider = str(row[4] or "local").lower() if provider != "local": continue - if not verify_password(password, row[2]): + verified, updated_hash = verify_and_update_password(password, row[2]) + if not verified: continue + if updated_hash: + with _connect() as conn: + conn.execute( + "UPDATE users SET password_hash = ? WHERE id = ?", + (updated_hash, row[0]), + ) return { "id": row[0], "username": row[1], @@ -1895,6 +2207,7 @@ def verify_user_password(username: str, password: str) -> Optional[Dict[str, Any "is_expired": _is_datetime_in_past(row[12]), "jellyfin_password_hash": row[15], "last_jellyfin_auth_at": row[16], + "auth_version": int(row[17] or 1), } return None @@ -1906,7 +2219,7 @@ def get_users_by_username_ci(username: str) -> list[Dict[str, Any]]: SELECT id, username, email, password_hash, role, auth_provider, jellyseerr_user_id, created_at, last_login_at, is_blocked, auto_search_enabled, invite_management_enabled, profile_id, expires_at, invited_by_code, invited_at, - jellyfin_password_hash, last_jellyfin_auth_at + jellyfin_password_hash, last_jellyfin_auth_at, auth_version FROM users WHERE username = ? COLLATE NOCASE ORDER BY @@ -1938,6 +2251,7 @@ def get_users_by_username_ci(username: str) -> list[Dict[str, Any]]: "is_expired": _is_datetime_in_past(row[13]), "jellyfin_password_hash": row[16], "last_jellyfin_auth_at": row[17], + "auth_version": int(row[18] or 1), } ) return results @@ -1956,7 +2270,7 @@ def set_user_email(username: str, email: Optional[str]) -> bool: ) updated = cursor.rowcount > 0 if updated: - logger.info("user email updated username=%s email=%s", username, normalized_email) + logger.info("user email updated username=%s email_set=%s", username, bool(normalized_email)) else: logger.debug("user email update skipped username=%s", username) return updated @@ -1967,12 +2281,74 @@ def set_user_password(username: str, password: str) -> None: with _connect() as conn: conn.execute( """ - UPDATE users SET password_hash = ? WHERE username = ? COLLATE NOCASE + UPDATE users + SET password_hash = ?, auth_version = auth_version + 1 + WHERE username = ? COLLATE NOCASE """, (password_hash, username), ) +def increment_user_auth_version(username: str) -> int: + with _connect() as conn: + conn.execute( + "UPDATE users SET auth_version = auth_version + 1 WHERE username = ? COLLATE NOCASE", + (username,), + ) + row = conn.execute( + "SELECT auth_version FROM users WHERE username = ? COLLATE NOCASE", + (username,), + ).fetchone() + return int(row[0] or 1) if row else 0 + + +def _rate_limit_key_hash(key: str) -> str: + key_material = str( + settings.jwt_secret or settings.settings_encryption_key or "magent-rate-limit" + ).encode("utf-8") + return hmac.new( + key_material, str(key or "").encode("utf-8"), sha256 + ).hexdigest() + + +def get_rate_limit_status( + scope: str, key: str, window_seconds: int, maximum: int +) -> tuple[bool, int]: + now = unix_time() + cutoff = now - max(1, int(window_seconds)) + key_hash = _rate_limit_key_hash(key) + with _connect() as conn: + conn.execute("DELETE FROM auth_rate_limits WHERE occurred_at < ?", (cutoff,)) + row = conn.execute( + """ + SELECT COUNT(*), MIN(occurred_at) + FROM auth_rate_limits + WHERE scope = ? AND key_hash = ? AND occurred_at >= ? + """, + (scope, key_hash, cutoff), + ).fetchone() + count = int((row or [0])[0] or 0) + oldest = float(row[1]) if row and row[1] is not None else now + retry_after = max(1, int(window_seconds - (now - oldest))) + return count >= max(1, int(maximum)), retry_after + + +def record_rate_limit_event(scope: str, key: str) -> None: + with _connect() as conn: + conn.execute( + "INSERT INTO auth_rate_limits (scope, key_hash, occurred_at) VALUES (?, ?, ?)", + (scope, _rate_limit_key_hash(key), unix_time()), + ) + + +def clear_rate_limit_events(scope: str, key: str) -> None: + with _connect() as conn: + conn.execute( + "DELETE FROM auth_rate_limits WHERE scope = ? AND key_hash = ?", + (scope, _rate_limit_key_hash(key)), + ) + + def sync_jellyfin_password_state(username: str, password: str) -> None: if not username or not password: return @@ -2943,11 +3319,12 @@ def get_setting(key: str) -> Optional[str]: ).fetchone() if not row: return None - return row[0] + return decrypt_setting_value(key, row[0]) def set_setting(key: str, value: Optional[str]) -> None: updated_at = datetime.now(timezone.utc).isoformat() + stored_value = encrypt_setting_value(key, value) with _connect() as conn: conn.execute( """ @@ -2955,7 +3332,7 @@ def set_setting(key: str, value: Optional[str]) -> None: VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at """, - (key, value, updated_at), + (key, stored_value, updated_at), ) @@ -2981,7 +3358,7 @@ def get_settings_overrides() -> Dict[str, str]: key = row[0] value = row[1] if key: - overrides[key] = value + overrides[key] = decrypt_setting_value(key, value) return overrides @@ -3067,12 +3444,10 @@ def create_password_reset_token( ), ) logger.info( - "password reset token created username=%s provider=%s recipient=%s expires_at=%s requester_ip=%s", + "password reset token created username=%s provider=%s expires_at=%s", username, auth_provider, - recipient_email, expires_at, - requested_by_ip, ) return { "username": username, @@ -3114,7 +3489,7 @@ def mark_password_reset_token_used(token_value: str) -> None: """, (used_at, token_hash), ) - logger.info("password reset token marked used token_hash=%s", token_hash[:12]) + logger.info("password reset token marked used") def get_seerr_media_failure(media_type: Optional[str], tmdb_id: Optional[int]) -> Optional[Dict[str, Any]]: @@ -4020,6 +4395,7 @@ def cleanup_history(days: int) -> Dict[str, int]: if days <= 0: return {"actions": 0, "snapshots": 0} cutoff = (datetime.now(timezone.utc) - timedelta(days=days)).isoformat() + cutoff_epoch = (datetime.now(timezone.utc) - timedelta(days=days)).timestamp() with _connect() as conn: actions = conn.execute( "DELETE FROM actions WHERE created_at < ?", @@ -4029,7 +4405,37 @@ def cleanup_history(days: int) -> Dict[str, int]: "DELETE FROM snapshots WHERE created_at < ?", (cutoff,), ).rowcount - return {"actions": actions, "snapshots": snapshots} + reset_tokens = conn.execute( + "DELETE FROM password_reset_tokens WHERE expires_at < ? OR (used_at IS NOT NULL AND used_at < ?)", + (cutoff, cutoff), + ).rowcount + invites = conn.execute( + """ + DELETE FROM signup_invites + WHERE updated_at < ? + AND (enabled = 0 OR expires_at < ? OR (max_uses IS NOT NULL AND use_count >= max_uses)) + AND id != COALESCE((SELECT CAST(value AS INTEGER) FROM settings WHERE key = 'self_service_invite_master_id'), -1) + """, + (cutoff, cutoff), + ).rowcount + rate_limits = conn.execute( + "DELETE FROM auth_rate_limits WHERE occurred_at < ?", + (unix_time() - 86400,), + ).rowcount + email_deliveries = 0 + for table in ("email_recap_deliveries", "newsletter_deliveries"): + if _table_exists(conn, table): + email_deliveries += int( + conn.execute(f"DELETE FROM {table} WHERE created_at < ?", (cutoff_epoch,)).rowcount or 0 + ) + return { + "actions": int(actions or 0), + "snapshots": int(snapshots or 0), + "password_reset_tokens": int(reset_tokens or 0), + "invites": int(invites or 0), + "rate_limits": int(rate_limits or 0), + "email_deliveries": email_deliveries, + } def get_request_stage_cache(): diff --git a/backend/app/logging_config.py b/backend/app/logging_config.py index 89cef15..2b11901 100644 --- a/backend/app/logging_config.py +++ b/backend/app/logging_config.py @@ -2,6 +2,7 @@ import contextvars import json import logging import os +import re from logging.handlers import RotatingFileHandler from typing import Any, Mapping, Optional from urllib.parse import parse_qs @@ -27,6 +28,9 @@ _SENSITIVE_KEYWORDS = ( "token", ) _MAX_BODY_BYTES = 4096 +_SENSITIVE_PATH_PATTERNS = ( + re.compile(r"(/auth/invites/)[^/]+", re.IGNORECASE), +) class RequestContextFilter(logging.Filter): @@ -47,6 +51,13 @@ def current_request_id() -> str: return REQUEST_ID_CONTEXT.get("-") +def sanitize_path(path: str) -> str: + sanitized = str(path or "") + for pattern in _SENSITIVE_PATH_PATTERNS: + sanitized = pattern.sub(r"\1[REDACTED]", sanitized) + return sanitized + + def _is_sensitive_key(key: str) -> bool: lowered = key.strip().lower() return any(marker in lowered for marker in _SENSITIVE_KEYWORDS) @@ -55,10 +66,7 @@ def _is_sensitive_key(key: str) -> bool: def _redact_scalar(value: Any) -> Any: if value is None or isinstance(value, (int, float, bool)): return value - text = str(value) - if len(text) <= 4: - return "***" - return f"{text[:2]}***{text[-2:]}" + return "[REDACTED]" def sanitize_value(value: Any, *, key_hint: Optional[str] = None, depth: int = 0) -> Any: @@ -161,6 +169,10 @@ def configure_logging( backupCount=max(1, int(log_file_backup_count or 10)), encoding="utf-8", ) + try: + os.chmod(log_path, 0o600) + except OSError: + pass handlers.append(file_handler) context_filter = RequestContextFilter() diff --git a/backend/app/main.py b/backend/app/main.py index 4b41b5e..15b6871 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -7,6 +7,7 @@ from typing import Awaitable, Callable from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse from .config import settings from .db import has_admin_user, init_db @@ -47,11 +48,11 @@ from .logging_config import ( configure_logging, reset_request_id, sanitize_headers, - sanitize_value, - summarize_http_body, + sanitize_path, ) from .runtime import get_runtime_settings from .metrics import record_api, start_metrics +from .secret_storage import validate_secret_storage_configuration logger = logging.getLogger(__name__) _background_tasks: list[asyncio.Task[None]] = [] @@ -82,22 +83,33 @@ async def log_requests_and_add_security_headers(request: Request, call_next): operation_token = begin_operation( operation_id, label=request.headers.get("X-Magent-Operation-Label"), - path=request.url.path, + path=sanitize_path(request.url.path), ) request.state.request_id = request_id + if request.method.upper() not in {"GET", "HEAD", "OPTIONS"}: + origin = str(request.headers.get("origin") or "").rstrip("/") + allowed_origin = str(settings.cors_allow_origin or "").rstrip("/") + if origin and origin != allowed_origin: + record_api(request, 403, 0.0) + if operation_id and operation_token is not None: + finish_operation(operation_id, success=False, status_code=403) + reset_operation(operation_token) + reset_request_id(token) + return JSONResponse( + status_code=403, + content={"detail": "Cross-origin state change rejected"}, + headers={"X-Request-ID": request_id}, + ) started_at = time.perf_counter() - body = await request.body() - body_summary = summarize_http_body(body, request.headers.get("content-type")) - - async def receive() -> dict: - return {"type": "http.request", "body": body, "more_body": False} - - request._receive = receive + body_summary = { + "content_type": (request.headers.get("content-type") or "").split(";", 1)[0], + "declared_bytes": request.headers.get("content-length"), + } logger.info( - "request started method=%s path=%s query=%s client=%s headers=%s body=%s", + "request started method=%s path=%s query_keys=%s client=%s headers=%s body=%s", request.method, - request.url.path, - sanitize_value(dict(request.query_params)), + sanitize_path(request.url.path), + sorted(set(request.query_params.keys())), request.client.host if request.client else "-", sanitize_headers( { @@ -124,7 +136,7 @@ async def log_requests_and_add_security_headers(request: Request, call_next): logger.exception( "request failed method=%s path=%s duration_ms=%s", request.method, - request.url.path, + sanitize_path(request.url.path), duration_ms, ) if operation_id and operation_token is not None: @@ -140,6 +152,7 @@ async def log_requests_and_add_security_headers(request: Request, call_next): response.headers.setdefault("X-Frame-Options", "DENY") response.headers.setdefault("Referrer-Policy", "no-referrer") response.headers.setdefault("Permissions-Policy", "geolocation=(), microphone=(), camera=()") + response.headers.setdefault("Strict-Transport-Security", "max-age=31536000; includeSubDomains") # Keep API responses non-executable and non-embeddable by default. if request.url.path not in {"/docs", "/redoc"} and not request.url.path.startswith("/openapi"): response.headers.setdefault( @@ -149,7 +162,7 @@ async def log_requests_and_add_security_headers(request: Request, call_next): logger.info( "request completed method=%s path=%s status=%s duration_ms=%s response_headers=%s", request.method, - request.url.path, + sanitize_path(request.url.path), response.status_code, duration_ms, sanitize_headers( @@ -203,9 +216,9 @@ def _launch_background_task(name: str, coroutine_factory: Callable[[], Awaitable def _log_security_configuration_warnings() -> None: jwt_secret = str(settings.jwt_secret or "").strip() - if not jwt_secret or jwt_secret == "change-me": + if len(jwt_secret) < 32 or jwt_secret == "change-me": logger.warning( - "security configuration warning: JWT_SECRET is unset or still set to the default value" + "security configuration warning: JWT_SECRET is missing, short, or still set to the default value" ) admin_password = str(settings.admin_password or "") if not admin_password or admin_password == "adminadmin": @@ -218,10 +231,17 @@ def _log_security_configuration_warnings() -> None: ) -def _enforce_secure_startup_configuration() -> None: +def _enforce_secret_configuration() -> None: jwt_secret = str(settings.jwt_secret or "").strip() - if not jwt_secret or jwt_secret == "change-me": - raise RuntimeError("JWT_SECRET must be set to a strong, non-default value before startup.") + if len(jwt_secret) < 32 or jwt_secret == "change-me": + raise RuntimeError( + "JWT_SECRET must be a strong, non-default value of at least 32 characters before startup." + ) + validate_secret_storage_configuration() + + +def _enforce_secure_startup_configuration() -> None: + _enforce_secret_configuration() admin_password = str(settings.admin_password or "") if not has_admin_user() and (not admin_password or admin_password == "adminadmin"): raise RuntimeError( @@ -242,6 +262,7 @@ async def startup() -> None: ) logger.info("startup begin app=%s build=%s", settings.app_name, settings.site_build_number) _log_security_configuration_warnings() + _enforce_secret_configuration() init_db() _enforce_secure_startup_configuration() runtime = get_runtime_settings() diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index 84d2954..3b06d8f 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -39,8 +39,7 @@ from ..db import ( set_user_jellyseerr_id, set_setting, set_user_blocked, - delete_user_by_username, - delete_user_activity_by_username, + delete_user_data_by_username, set_user_auto_search_enabled, set_auto_search_enabled_for_non_admin_users, set_user_email, @@ -49,6 +48,7 @@ from ..db import ( set_user_profile_id, set_user_expires_at, set_user_password, + increment_user_auth_version, sync_jellyfin_password_state, set_user_role, run_integrity_check, @@ -69,6 +69,7 @@ from ..db import ( get_signup_invite_by_id, create_signup_invite, update_signup_invite, + rotate_signup_invite_code, delete_signup_invite, get_signup_invite_by_code, disable_signup_invites_by_creator, @@ -779,7 +780,7 @@ async def test_email_settings(request: Request) -> Dict[str, Any]: result = await send_test_email(recipient_email=recipient_email) except RuntimeError as exc: raise HTTPException(status_code=502, detail=str(exc)) from exc - logger.info("Admin triggered SMTP test: recipient=%s", result.get("recipient_email")) + logger.info("Admin triggered SMTP test") return {"status": "ok", **result} @@ -1307,12 +1308,12 @@ async def user_system_action(username: str, payload: Dict[str, Any]) -> Dict[str result["jellyseerr"] = {"status": "error", "detail": _http_error_detail(exc)} if action == "remove": - deleted = delete_user_by_username(username) - activity_deleted = delete_user_activity_by_username(username) + deletion = delete_user_data_by_username(username) + deleted = bool(deletion.get("deleted")) result["local"] = { "status": "ok" if deleted else "not_found", "deleted": bool(deleted), - "activity_deleted": activity_deleted, + "data_cleanup": deletion, } if any( @@ -1574,6 +1575,7 @@ async def update_user_password(username: str, payload: Dict[str, Any]) -> Dict[s except Exception as exc: raise HTTPException(status_code=502, detail=f"Jellyfin password update failed: {exc}") from exc sync_jellyfin_password_state(username, new_password_clean) + increment_user_auth_version(username) return {"status": "ok", "username": username, "provider": "jellyfin"} raise HTTPException( status_code=400, @@ -1917,6 +1919,11 @@ async def send_invite_email(payload: Dict[str, Any]) -> Dict[str, Any]: role=invite.get('role'), max_uses=1, enabled=bool(invite.get('enabled')), expires_at=invite.get('expires_at'), recipient_email=recipient_email, ) + if not invite: + raise HTTPException(status_code=404, detail='Invite not found') + invite = rotate_signup_invite_code(int(invite['id']), _generate_invite_code()) + if not invite: + raise HTTPException(status_code=409, detail='Invite is unavailable') try: result = await send_templated_email( @@ -1930,9 +1937,8 @@ async def send_invite_email(payload: Dict[str, Any]) -> Dict[str, Any]: except Exception as exc: raise HTTPException(status_code=502, detail=str(exc)) from exc logger.info( - "Admin sent invite email template: template=%s recipient=%s invite_id=%s username=%s", + "Admin sent invite email template: template=%s invite_id=%s username=%s", template_key, - result.get("recipient_email"), invite.get("id") if invite else None, user.get("username") if user else None, ) @@ -1998,15 +2004,14 @@ async def create_invite(payload: Dict[str, Any], current_user: Dict[str, Any] = except Exception as exc: email_error = str(exc) logger.info( - "Admin created invite: invite_id=%s code=%s label=%s profile_id=%s role=%s max_uses=%s enabled=%s recipient_email=%s send_email=%s", + "Admin created invite: invite_id=%s label=%s profile_id=%s role=%s max_uses=%s enabled=%s has_recipient=%s send_email=%s", invite.get("id"), - invite.get("code"), invite.get("label"), invite.get("profile_id"), invite.get("role"), invite.get("max_uses"), invite.get("enabled"), - invite.get("recipient_email"), + bool(invite.get("recipient_email")), send_email, ) return { @@ -2029,7 +2034,11 @@ async def edit_invite(invite_id: int, payload: Dict[str, Any]) -> Dict[str, Any] existing = get_signup_invite_by_id(invite_id) if not existing: raise HTTPException(status_code=404, detail="Invite not found") - code = _normalize_invite_code(_normalize_optional_text(payload.get("code")) or existing["code"]) + requested_code = _normalize_optional_text(payload.get("code")) + if requested_code and not requested_code.startswith("••••") and requested_code != "Protected invite": + code = _normalize_invite_code(requested_code) + else: + code = str(existing.get("code") or "") profile_id = _parse_optional_profile_id(payload.get("profile_id")) enabled = payload.get("enabled") if not isinstance(enabled, bool): @@ -2063,6 +2072,10 @@ async def edit_invite(invite_id: int, payload: Dict[str, Any]) -> Dict[str, Any] email_error = None if send_email: try: + rotated = rotate_signup_invite_code(invite_id, _generate_invite_code()) + if not rotated: + raise ValueError("Invite is unavailable") + invite = rotated email_result = await send_templated_email( "invited", invite=invite, @@ -2072,15 +2085,14 @@ async def edit_invite(invite_id: int, payload: Dict[str, Any]) -> Dict[str, Any] except Exception as exc: email_error = str(exc) logger.info( - "Admin updated invite: invite_id=%s code=%s label=%s profile_id=%s role=%s max_uses=%s enabled=%s recipient_email=%s send_email=%s", + "Admin updated invite: invite_id=%s label=%s profile_id=%s role=%s max_uses=%s enabled=%s has_recipient=%s send_email=%s", invite.get("id"), - invite.get("code"), invite.get("label"), invite.get("profile_id"), invite.get("role"), invite.get("max_uses"), invite.get("enabled"), - invite.get("recipient_email"), + bool(invite.get("recipient_email")), send_email, ) return { @@ -2096,6 +2108,22 @@ async def edit_invite(invite_id: int, payload: Dict[str, Any]) -> Dict[str, Any] } +@router.post("/invites/{invite_id}/rotate") +async def rotate_invite( + invite_id: int, + current_user: Dict[str, Any] = Depends(require_admin), +) -> Dict[str, Any]: + invite = rotate_signup_invite_code(invite_id, _generate_invite_code()) + if not invite: + raise HTTPException(status_code=409, detail="Invite is unavailable") + logger.info( + "Admin rotated invite: invite_id=%s actor=%s", + invite_id, + current_user.get("username"), + ) + return {"status": "ok", "invite": invite} + + @router.delete("/invites/{invite_id}") async def remove_invite(invite_id: int) -> Dict[str, Any]: deleted = delete_signup_invite(invite_id) diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index adf7c04..202d97c 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -1,11 +1,8 @@ from ..feature_guards import require_invites from datetime import datetime, timedelta, timezone -from collections import defaultdict, deque import logging import secrets import string -import time -from threading import Lock import httpx from fastapi import APIRouter, HTTPException, status, Depends, Request, Response @@ -28,6 +25,7 @@ from ..db import ( list_signup_invites, create_signup_invite, update_signup_invite, + rotate_signup_invite_code, delete_signup_invite, reserve_signup_invite_use, release_signup_invite_use, @@ -39,6 +37,10 @@ from ..db import ( get_global_request_total, get_setting, sync_jellyfin_password_state, + increment_user_auth_version, + get_rate_limit_status, + record_rate_limit_event, + clear_rate_limit_events, ) from ..runtime import get_runtime_settings from ..clients.jellyfin import JellyfinClient @@ -87,14 +89,6 @@ PASSWORD_RESET_GENERIC_MESSAGE = ( "If an account exists for that username or email, a password reset link has been sent." ) -_LOGIN_RATE_LOCK = Lock() -_LOGIN_ATTEMPTS_BY_IP: dict[str, deque[float]] = defaultdict(deque) -_LOGIN_ATTEMPTS_BY_USER: dict[str, deque[float]] = defaultdict(deque) -_RESET_RATE_LOCK = Lock() -_RESET_ATTEMPTS_BY_IP: dict[str, deque[float]] = defaultdict(deque) -_RESET_ATTEMPTS_BY_IDENTIFIER: dict[str, deque[float]] = defaultdict(deque) - - def _require_recipient_email(value: object) -> str: normalized = normalize_delivery_email(value) if normalized: @@ -145,12 +139,6 @@ def _password_reset_rate_key_identifier(identifier: str) -> str: return (identifier or "").strip().lower()[:256] or "" -def _prune_attempts(bucket: deque[float], now: float, window_seconds: int) -> None: - cutoff = now - window_seconds - while bucket and bucket[0] < cutoff: - bucket.popleft() - - def _pick_preferred_ci_user_match(users: list[dict], requested_username: str) -> dict | None: if not users: return None @@ -172,56 +160,33 @@ def _pick_preferred_ci_user_match(users: list[dict], requested_username: str) -> def _record_login_failure(request: Request, username: str) -> None: - now = time.monotonic() - window = max(int(settings.auth_rate_limit_window_seconds or 60), 1) ip_key = _auth_client_ip(request) user_key = _login_rate_key_user(username) - with _LOGIN_RATE_LOCK: - ip_bucket = _LOGIN_ATTEMPTS_BY_IP[ip_key] - user_bucket = _LOGIN_ATTEMPTS_BY_USER[user_key] - _prune_attempts(ip_bucket, now, window) - _prune_attempts(user_bucket, now, window) - ip_bucket.append(now) - user_bucket.append(now) - logger.warning("login failure recorded username=%s client=%s", user_key, ip_key) + record_rate_limit_event("login-ip", ip_key) + record_rate_limit_event("login-user", user_key) + logger.warning("login failure recorded") def _clear_login_failures(request: Request, username: str) -> None: ip_key = _auth_client_ip(request) user_key = _login_rate_key_user(username) - with _LOGIN_RATE_LOCK: - _LOGIN_ATTEMPTS_BY_IP.pop(ip_key, None) - _LOGIN_ATTEMPTS_BY_USER.pop(user_key, None) + clear_rate_limit_events("login-ip", ip_key) + clear_rate_limit_events("login-user", user_key) def _enforce_login_rate_limit(request: Request, username: str) -> None: - now = time.monotonic() window = max(int(settings.auth_rate_limit_window_seconds or 60), 1) max_ip = max(int(settings.auth_rate_limit_max_attempts_ip or 20), 1) max_user = max(int(settings.auth_rate_limit_max_attempts_user or 10), 1) ip_key = _auth_client_ip(request) user_key = _login_rate_key_user(username) - with _LOGIN_RATE_LOCK: - ip_bucket = _LOGIN_ATTEMPTS_BY_IP[ip_key] - user_bucket = _LOGIN_ATTEMPTS_BY_USER[user_key] - _prune_attempts(ip_bucket, now, window) - _prune_attempts(user_bucket, now, window) - exceeded = len(ip_bucket) >= max_ip or len(user_bucket) >= max_user - retry_after = 1 - if exceeded: - retry_candidates = [] - if ip_bucket: - retry_candidates.append(max(1, int(window - (now - ip_bucket[0])))) - if user_bucket: - retry_candidates.append(max(1, int(window - (now - user_bucket[0])))) - if retry_candidates: - retry_after = max(retry_candidates) + ip_exceeded, ip_retry = get_rate_limit_status("login-ip", ip_key, window, max_ip) + user_exceeded, user_retry = get_rate_limit_status("login-user", user_key, window, max_user) + exceeded = ip_exceeded or user_exceeded + retry_after = max(ip_retry if ip_exceeded else 1, user_retry if user_exceeded else 1) if exceeded: logger.warning( - "login rate limit exceeded username=%s client=%s retry_after=%s", - user_key, - ip_key, - retry_after, + "login rate limit exceeded retry_after=%s", retry_after, ) raise HTTPException( status_code=status.HTTP_429_TOO_MANY_REQUESTS, @@ -231,48 +196,28 @@ def _enforce_login_rate_limit(request: Request, username: str) -> None: def _record_password_reset_attempt(request: Request, identifier: str) -> None: - now = time.monotonic() - window = max(int(settings.password_reset_rate_limit_window_seconds or 300), 1) ip_key = _auth_client_ip(request) identifier_key = _password_reset_rate_key_identifier(identifier) - with _RESET_RATE_LOCK: - ip_bucket = _RESET_ATTEMPTS_BY_IP[ip_key] - identifier_bucket = _RESET_ATTEMPTS_BY_IDENTIFIER[identifier_key] - _prune_attempts(ip_bucket, now, window) - _prune_attempts(identifier_bucket, now, window) - ip_bucket.append(now) - identifier_bucket.append(now) - logger.info("password reset rate event recorded identifier=%s client=%s", identifier_key, ip_key) + record_rate_limit_event("reset-ip", ip_key) + record_rate_limit_event("reset-identifier", identifier_key) + logger.info("password reset rate event recorded") def _enforce_password_reset_rate_limit(request: Request, identifier: str) -> None: - now = time.monotonic() window = max(int(settings.password_reset_rate_limit_window_seconds or 300), 1) max_ip = max(int(settings.password_reset_rate_limit_max_attempts_ip or 6), 1) max_identifier = max(int(settings.password_reset_rate_limit_max_attempts_identifier or 3), 1) ip_key = _auth_client_ip(request) identifier_key = _password_reset_rate_key_identifier(identifier) - with _RESET_RATE_LOCK: - ip_bucket = _RESET_ATTEMPTS_BY_IP[ip_key] - identifier_bucket = _RESET_ATTEMPTS_BY_IDENTIFIER[identifier_key] - _prune_attempts(ip_bucket, now, window) - _prune_attempts(identifier_bucket, now, window) - exceeded = len(ip_bucket) >= max_ip or len(identifier_bucket) >= max_identifier - retry_after = 1 - if exceeded: - retry_candidates = [] - if ip_bucket: - retry_candidates.append(max(1, int(window - (now - ip_bucket[0])))) - if identifier_bucket: - retry_candidates.append(max(1, int(window - (now - identifier_bucket[0])))) - if retry_candidates: - retry_after = max(retry_candidates) + ip_exceeded, ip_retry = get_rate_limit_status("reset-ip", ip_key, window, max_ip) + identifier_exceeded, identifier_retry = get_rate_limit_status( + "reset-identifier", identifier_key, window, max_identifier + ) + exceeded = ip_exceeded or identifier_exceeded + retry_after = max(ip_retry if ip_exceeded else 1, identifier_retry if identifier_exceeded else 1) if exceeded: logger.warning( - "password reset rate limit exceeded identifier=%s client=%s retry_after=%s", - identifier_key, - ip_key, - retry_after, + "password reset rate limit exceeded retry_after=%s", retry_after, ) raise HTTPException( status_code=status.HTTP_429_TOO_MANY_REQUESTS, @@ -400,6 +345,7 @@ def _auth_success_response(response: Response, token: str, user_payload: dict) - def _public_invite_payload(invite: dict, profile: dict | None = None) -> dict: return { "code": invite.get("code"), + "code_available": bool(invite.get("code_available")), "email_bound": bool(invite.get("recipient_email")), "label": invite.get("label"), "description": invite.get("description"), @@ -493,6 +439,7 @@ def _serialize_self_invite(invite: dict) -> dict: return { "id": invite.get("id"), "code": invite.get("code"), + "code_available": bool(invite.get("code_available")), "label": invite.get("label"), "description": invite.get("description"), "profile_id": invite.get("profile_id"), @@ -576,6 +523,7 @@ def _serialize_self_service_master_invite(invite: dict | None) -> dict | None: return { "id": invite.get("id"), "code": invite.get("code"), + "code_available": bool(invite.get("code_available")), "label": invite.get("label"), "description": invite.get("description"), "profile_id": invite.get("profile_id"), @@ -664,7 +612,9 @@ async def login( detail="This account uses external sign-in. Use the external sign-in option.", ) _assert_user_can_login(user) - token = create_access_token(user["username"], user["role"]) + token = create_access_token( + user["username"], user["role"], auth_version=int(user.get("auth_version") or 1) + ) _clear_login_failures(request, form_data.username) set_last_login(user["username"]) logger.info( @@ -708,7 +658,9 @@ async def jellyfin_login( matched_email = extract_jellyseerr_user_email(matched_seerr_user) _assert_user_can_login(user) if user and _has_valid_jellyfin_cache(user, password): - token = create_access_token(canonical_username, "user") + token = create_access_token( + canonical_username, "user", auth_version=int(user.get("auth_version") or 1) + ) _clear_login_failures(request, username) set_last_login(canonical_username) logger.info( @@ -775,7 +727,10 @@ async def jellyfin_login( matched_id = match_jellyseerr_user_id(canonical_username, candidate_map) if matched_id is not None: set_user_jellyseerr_id(canonical_username, matched_id) - token = create_access_token(canonical_username, "user") + refreshed_user = get_user_by_username(canonical_username) or user or {} + token = create_access_token( + canonical_username, "user", auth_version=int(refreshed_user.get("auth_version") or 1) + ) _clear_login_failures(request, username) set_last_login(canonical_username) logger.info( @@ -851,7 +806,10 @@ async def jellyseerr_login( set_user_jellyseerr_id(canonical_username, jellyseerr_user_id) if jellyseerr_email: set_user_email(canonical_username, jellyseerr_email) - token = create_access_token(canonical_username, "user") + refreshed_user = get_user_by_username(canonical_username) or user or {} + token = create_access_token( + canonical_username, "user", auth_version=int(refreshed_user.get("auth_version") or 1) + ) _clear_login_failures(request, form_data.username) set_last_login(canonical_username) logger.info( @@ -873,7 +831,10 @@ async def me(current_user: dict = Depends(get_current_user)) -> dict: @router.post("/logout") -async def logout(response: Response) -> dict: +async def logout( + response: Response, current_user: dict = Depends(get_current_user) +) -> dict: + increment_user_auth_version(str(current_user.get("username") or "")) clear_auth_cookies(response) return {"status": "ok"} @@ -884,6 +845,7 @@ async def stream_token(current_user: dict = Depends(get_current_user)) -> dict: current_user["username"], current_user["role"], expires_seconds=STREAM_TOKEN_TTL_SECONDS, + auth_version=int(current_user.get("auth_version") or 1), ) return { "stream_token": token, @@ -923,11 +885,7 @@ async def signup(payload: dict, response: Response) -> dict: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc if get_user_by_username(username): raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="User already exists") - logger.info( - "signup attempt username=%s invite_code=%s", - username, - invite_code, - ) + logger.info("signup attempt username=%s", username) invite = get_signup_invite_by_code(invite_code) if not invite: @@ -1039,7 +997,7 @@ async def signup(payload: dict, response: Response) -> dict: auto_search_enabled=auto_search_enabled, profile_id=int(profile_id) if profile_id is not None else None, expires_at=expires_at, - invited_by_code=invite.get("code"), + invited_by_code=f"invite:{invite.get('id')}", ) except Exception as exc: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc @@ -1066,15 +1024,18 @@ async def signup(payload: dict, response: Response) -> dict: # Welcome email delivery is best-effort and must not break signup. logger.warning("Welcome email send skipped for %s: %s", username, exc) _assert_user_can_login(created_user) - token = create_access_token(username, role) + refreshed_user = get_user_by_username(username) or created_user or {} + token = create_access_token( + username, role, auth_version=int(refreshed_user.get("auth_version") or 1) + ) set_last_login(username) logger.info( - "signup success username=%s role=%s auth_provider=%s profile_id=%s invite_code=%s", + "signup success username=%s role=%s auth_provider=%s profile_id=%s invite_id=%s", username, role, created_user.get("auth_provider") if created_user else auth_provider, created_user.get("profile_id") if created_user else None, - invite.get("code"), + invite.get("id"), ) return _auth_success_response( response, @@ -1110,8 +1071,7 @@ async def forgot_password(payload: dict, request: Request) -> dict: ) client_ip = _auth_client_ip(request) - safe_identifier = identifier.strip().lower()[:256] - logger.info("password reset requested identifier=%s client=%s", safe_identifier, client_ip) + logger.info("password reset requested") try: reset_result = await request_password_reset( identifier, @@ -1120,24 +1080,17 @@ async def forgot_password(payload: dict, request: Request) -> dict: ) if reset_result.get("issued"): logger.info( - "password reset issued username=%s provider=%s recipient=%s client=%s", + "password reset issued username=%s provider=%s", reset_result.get("username"), reset_result.get("auth_provider"), - reset_result.get("recipient_email"), - client_ip, ) else: logger.info( - "password reset request completed with no eligible account identifier=%s client=%s", - safe_identifier, - client_ip, + "password reset request completed with no eligible account", ) except Exception as exc: logger.warning( - "password reset email dispatch failed identifier=%s client=%s detail=%s", - safe_identifier, - client_ip, - str(exc), + "password reset email dispatch failed detail=%s", type(exc).__name__, ) return {"status": "ok", "message": PASSWORD_RESET_GENERIC_MESSAGE} @@ -1371,8 +1324,13 @@ async def update_profile_invite( _require_self_service_invite_access(current_user) existing = _get_owned_invite(invite_id, current_user) - requested_code = payload.get("code", existing.get("code")) - if isinstance(requested_code, str) and requested_code.strip(): + requested_code = payload.get("code") + if ( + isinstance(requested_code, str) + and requested_code.strip() + and not requested_code.strip().startswith("••••") + and requested_code.strip() != "Protected invite" + ): code = _normalize_invite_code(requested_code) else: code = str(existing.get("code") or "").strip() @@ -1427,6 +1385,10 @@ async def update_profile_invite( email_error = None if send_email: try: + rotated = rotate_signup_invite_code(invite_id, _generate_invite_code()) + if not rotated: + raise ValueError("Invite is unavailable") + invite = rotated email_result = await send_templated_email( "invited", invite=invite, @@ -1450,6 +1412,18 @@ async def update_profile_invite( } +@router.post("/profile/invites/{invite_id}/rotate") +async def rotate_profile_invite( + invite_id: int, current_user: dict = Depends(get_current_user) +) -> dict: + _require_self_service_invite_access(current_user) + _get_owned_invite(invite_id, current_user) + invite = rotate_signup_invite_code(invite_id, _generate_invite_code()) + if not invite: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Invite is unavailable") + return {"status": "ok", "invite": _serialize_self_invite(invite)} + + @router.delete("/profile/invites/{invite_id}") async def delete_profile_invite(invite_id: int, current_user: dict = Depends(get_current_user)) -> dict: _require_self_service_invite_access(current_user) @@ -1531,6 +1505,7 @@ async def change_password(payload: dict, current_user: dict = Depends(get_curren # Keep Magent's password hash and Jellyfin auth cache aligned with Jellyfin. sync_jellyfin_password_state(username, new_password_clean) + increment_user_auth_version(username) logger.info("password change completed username=%s provider=jellyfin", username) return {"status": "ok", "provider": "jellyfin"} diff --git a/backend/app/routers/branding.py b/backend/app/routers/branding.py index a01ae85..6a47535 100644 --- a/backend/app/routers/branding.py +++ b/backend/app/routers/branding.py @@ -1,4 +1,5 @@ import os +import warnings from io import BytesIO from typing import Any, Dict @@ -15,6 +16,10 @@ _BUNDLED_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "as _BUNDLED_LOGO_PATH = os.path.join(_BUNDLED_DIR, "logo.png") _BUNDLED_FAVICON_PATH = os.path.join(_BUNDLED_DIR, "favicon.ico") _BRANDING_SOURCE = os.getenv("BRANDING_SOURCE", "bundled").lower() +_MAX_UPLOAD_BYTES = 5 * 1024 * 1024 +_MAX_IMAGE_PIXELS = 25_000_000 +_ALLOWED_IMAGE_TYPES = {"image/png", "image/jpeg", "image/webp"} +_ALLOWED_IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp"} def _ensure_branding_dir() -> None: @@ -110,14 +115,27 @@ async def branding_favicon() -> FileResponse: async def save_branding_image(file: UploadFile) -> Dict[str, Any]: - if not file.content_type or not file.content_type.startswith("image/"): - raise HTTPException(status_code=400, detail="Please upload an image file.") - content = await file.read() + content_type = str(file.content_type or "").lower() + extension = os.path.splitext(str(file.filename or ""))[1].lower() + if content_type not in _ALLOWED_IMAGE_TYPES or extension not in _ALLOWED_IMAGE_EXTENSIONS: + raise HTTPException(status_code=400, detail="Upload a PNG, JPEG, or WebP image.") + content = await file.read(_MAX_UPLOAD_BYTES + 1) if not content: raise HTTPException(status_code=400, detail="Uploaded file is empty.") + if len(content) > _MAX_UPLOAD_BYTES: + raise HTTPException(status_code=413, detail="Image is too large (maximum 5 MB).") try: - image = Image.open(BytesIO(content)) - except OSError as exc: + with warnings.catch_warnings(): + warnings.simplefilter("error", Image.DecompressionBombWarning) + candidate = Image.open(BytesIO(content)) + if candidate.format not in {"PNG", "JPEG", "WEBP"}: + raise ValueError("Unsupported image format") + if candidate.width * candidate.height > _MAX_IMAGE_PIXELS: + raise Image.DecompressionBombError("Image pixel limit exceeded") + candidate.verify() + image = Image.open(BytesIO(content)) + image.load() + except (OSError, ValueError, Image.DecompressionBombError, Image.DecompressionBombWarning) as exc: raise HTTPException(status_code=400, detail="Image file could not be read.") from exc _ensure_branding_dir() diff --git a/backend/app/routers/requests.py b/backend/app/routers/requests.py index c9198cd..a921a95 100644 --- a/backend/app/routers/requests.py +++ b/backend/app/routers/requests.py @@ -1593,11 +1593,54 @@ def get_requests_sync_state() -> Dict[str, Any]: async def _ensure_request_access( - client: JellyseerrClient, request_id: int, user: Dict[str, str] -) -> None: - if user.get("role") == "admin" or user.get("username"): - return - raise HTTPException(status_code=403, detail="Request not accessible for this user") + client: JellyseerrClient, + request_id: int, + user: Dict[str, Any], + *, + require_owner: bool = False, +) -> Optional[Dict[str, Any]]: + if user.get("role") == "admin": + return None + if not user.get("username"): + raise HTTPException(status_code=403, detail="Request not accessible for this user") + if not require_owner: + return None + request_data = await client.get_request(str(request_id)) + if not isinstance(request_data, dict): + raise HTTPException(status_code=404, detail="Request not found") + requester_id = _extract_requested_by_id(request_data) + current_seerr_id = user.get("jellyseerr_user_id") + if isinstance(current_seerr_id, int) and requester_id == current_seerr_id: + return request_data + if _request_matches_user(request_data, str(user.get("username") or "")): + return request_data + email = str(user.get("email") or "").strip() + if email and _request_matches_user(request_data, email): + return request_data + raise HTTPException( + status_code=403, + detail="Only the original requester or an administrator can change this request", + ) + + +async def _ensure_request_mutation_access( + runtime: Any, request_id: int, user: Dict[str, Any] +) -> Optional[Dict[str, Any]]: + """Fail closed when a non-admin request owner cannot be verified.""" + if user.get("role") == "admin": + return None + client = JellyseerrClient( + getattr(runtime, "jellyseerr_base_url", None), + getattr(runtime, "jellyseerr_api_key", None), + ) + if not client.configured(): + raise HTTPException( + status_code=403, + detail="Request ownership cannot be verified while Seerr is unavailable", + ) + return await _ensure_request_access( + client, request_id, user, require_owner=True + ) def _build_recent_map(response: Dict[str, Any]) -> Dict[int, Dict[str, Any]]: @@ -1948,9 +1991,6 @@ async def issue_target_options( if not request_id.isdigit(): raise HTTPException(status_code=400, detail="Invalid request id") runtime = get_runtime_settings() - seerr = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key) - if seerr.configured(): - await _ensure_request_access(seerr, int(request_id), user) snapshot = await build_snapshot(request_id) arr_item = snapshot.raw.get("arr", {}).get("item") if not isinstance(arr_item, dict): @@ -2136,9 +2176,7 @@ async def action_replace_media( ) runtime = get_runtime_settings() - seerr = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key) - if seerr.configured(): - await _ensure_request_access(seerr, int(request_id), user) + await _ensure_request_mutation_access(runtime, int(request_id), user) snapshot = await build_snapshot(request_id) arr_item = snapshot.raw.get("arr", {}).get("item") if not isinstance(arr_item, dict): @@ -2360,6 +2398,7 @@ async def action_search_missing_media( payload.get("season_numbers"), field="season_numbers", maximum=100, minimum=0 ) runtime = get_runtime_settings() + await _ensure_request_mutation_access(runtime, int(request_id), user) snapshot = await build_snapshot(request_id) arr_item = snapshot.raw.get("arr", {}).get("item") if not isinstance(arr_item, dict) or not isinstance(arr_item.get("id"), int): @@ -2497,9 +2536,7 @@ async def action_add_seasons( raise HTTPException(status_code=400, detail="Choose at least one season") runtime = get_runtime_settings() - seerr = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key) - if seerr.configured(): - await _ensure_request_access(seerr, int(request_id), user) + await _ensure_request_mutation_access(runtime, int(request_id), user) snapshot = await build_snapshot(request_id) if snapshot.request_type != RequestType.tv: raise HTTPException(status_code=400, detail="Additional seasons are only available for TV requests") @@ -2627,6 +2664,7 @@ async def action_repair_subtitles( episode_ids = _positive_id_list(payload.get("episode_ids"), field="episode_ids", maximum=100) forced = payload.get("forced") is True runtime = get_runtime_settings() + await _ensure_request_mutation_access(runtime, int(request_id), user) bazarr = BazarrClient(runtime.bazarr_base_url, runtime.bazarr_api_key) if not bazarr.configured() or not runtime.bazarr_api_key: raise HTTPException(status_code=400, detail="Bazarr is not configured") @@ -2772,33 +2810,36 @@ async def action_recheck(request_id: str, user: Dict[str, str] = Depends(get_cur seerr = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key) if not seerr.configured(): raise HTTPException(status_code=400, detail="Seerr is not configured") - await _ensure_request_access(seerr, int(request_id), user) + fresh_request = await _ensure_request_access( + seerr, int(request_id), user, require_owner=True + ) - try: - fresh_request = await seerr.get_request(request_id) - except httpx.HTTPStatusError as exc: - detail = _format_upstream_error("Seerr", exc) - await asyncio.to_thread( - save_action, - request_id, - "recheck_pipeline", - "Recheck request status", - "failed", - detail, - ) - raise HTTPException(status_code=502, detail=detail) from exc - except Exception as exc: - logger.warning("Request recheck failed while reading Seerr: request_id=%s error=%s", request_id, exc) - detail = "Magent could not reach Seerr to recheck this request." - await asyncio.to_thread( - save_action, - request_id, - "recheck_pipeline", - "Recheck request status", - "failed", - detail, - ) - raise HTTPException(status_code=502, detail=detail) from exc + if fresh_request is None: + try: + fresh_request = await seerr.get_request(request_id) + except httpx.HTTPStatusError as exc: + detail = _format_upstream_error("Seerr", exc) + await asyncio.to_thread( + save_action, + request_id, + "recheck_pipeline", + "Recheck request status", + "failed", + detail, + ) + raise HTTPException(status_code=502, detail=detail) from exc + except Exception as exc: + logger.warning("Request recheck failed while reading Seerr: request_id=%s error=%s", request_id, exc) + detail = "Magent could not reach Seerr to recheck this request." + await asyncio.to_thread( + save_action, + request_id, + "recheck_pipeline", + "Recheck request status", + "failed", + detail, + ) + raise HTTPException(status_code=502, detail=detail) from exc if not isinstance(fresh_request, dict): raise HTTPException(status_code=404, detail="Request not found in Seerr") @@ -3444,11 +3485,14 @@ async def ai_triage(request_id: str, user: Dict[str, str] = Depends(get_current_ return triage_snapshot(snapshot) -async def _request_language_context(request_id, user): +async def _request_language_context(request_id, user, *, require_owner: bool = False): runtime = get_runtime_settings() seerr = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key) - await _ensure_request_access(seerr, int(request_id), user) - request = await seerr.get_request(request_id) + request = await _ensure_request_access( + seerr, int(request_id), user, require_owner=require_owner + ) + if request is None: + request = await seerr.get_request(request_id) if not isinstance(request, dict) or request.get('type') != 'movie': return runtime, None, None tmdb_id = (request.get('media') or {}).get('tmdbId') @@ -3479,7 +3523,9 @@ async def accept_request_language(request_id: str, payload: dict, user: dict = D raise HTTPException(403, 'Search and download changes are disabled for this account.') if payload.get('acceptOriginalLanguage') is not True: raise HTTPException(400, 'Explicitly accept original-language audio before continuing.') - runtime, tmdb_id, language = await _request_language_context(request_id, user) + runtime, tmdb_id, language = await _request_language_context( + request_id, user, require_owner=True + ) if not language: raise HTTPException(409, 'This request has no verified non-English original language.') if payload.get('languageCode') != language['code']: @@ -3502,9 +3548,7 @@ async def action_search(request_id: str, user: Dict[str, str] = Depends(get_curr total_missing = 0 next_offset = None runtime = get_runtime_settings() - client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key) - if client.configured(): - await _ensure_request_access(client, int(request_id), user) + await _ensure_request_mutation_access(runtime, int(request_id), user) snapshot = await build_snapshot(request_id) arr_item = snapshot.raw.get("arr", {}).get("item") if not isinstance(arr_item, dict) or not isinstance(arr_item.get("id"), int): @@ -3612,9 +3656,7 @@ async def action_search_auto(request_id: str, user: Dict[str, str] = Depends(get if not _user_can_use_search_auto(user): raise HTTPException(status_code=403, detail="Auto search and download is disabled for this user") runtime = get_runtime_settings() - client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key) - if client.configured(): - await _ensure_request_access(client, int(request_id), user) + await _ensure_request_mutation_access(runtime, int(request_id), user) snapshot = await build_snapshot(request_id) arr_item = snapshot.raw.get("arr", {}).get("item") if not isinstance(arr_item, dict): @@ -3664,9 +3706,7 @@ async def action_search_auto(request_id: str, user: Dict[str, str] = Depends(get @router.post("/{request_id}/actions/qbit/resume") async def action_resume(request_id: str, user: Dict[str, str] = Depends(get_current_user)) -> dict: runtime = get_runtime_settings() - client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key) - if client.configured(): - await _ensure_request_access(client, int(request_id), user) + await _ensure_request_mutation_access(runtime, int(request_id), user) snapshot = await build_snapshot(request_id) queue = snapshot.raw.get("arr", {}).get("queue") download_ids = _download_ids(_queue_records(queue)) @@ -3711,9 +3751,7 @@ async def action_resume(request_id: str, user: Dict[str, str] = Depends(get_curr @router.post("/{request_id}/actions/readd") async def action_readd(request_id: str, user: Dict[str, str] = Depends(get_current_user)) -> dict: runtime = get_runtime_settings() - client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key) - if client.configured(): - await _ensure_request_access(client, int(request_id), user) + await _ensure_request_mutation_access(runtime, int(request_id), user) snapshot = await build_snapshot(request_id) jelly = snapshot.raw.get("jellyseerr") or {} media = jelly.get("media") or {} @@ -3870,9 +3908,7 @@ async def action_grab( request_id: str, payload: Dict[str, Any], user: Dict[str, str] = Depends(get_current_user) ) -> dict: runtime = get_runtime_settings() - client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key) - if client.configured(): - await _ensure_request_access(client, int(request_id), user) + await _ensure_request_mutation_access(runtime, int(request_id), user) snapshot = await build_snapshot(request_id) guid = payload.get("guid") indexer_id = payload.get("indexerId") diff --git a/backend/app/secret_storage.py b/backend/app/secret_storage.py new file mode 100644 index 0000000..74ac830 --- /dev/null +++ b/backend/app/secret_storage.py @@ -0,0 +1,74 @@ +import base64 +import hashlib +from typing import Optional + +from cryptography.fernet import Fernet, InvalidToken + +from .config import settings + + +ENCRYPTED_PREFIX = "enc:v1:" +SENSITIVE_SETTING_KEYS = frozenset( + { + "jellystat_api_key", "magent_ssl_certificate_pem", "magent_ssl_private_key_pem", + "magent_notify_email_smtp_password", "magent_notify_discord_webhook_url", + "magent_notify_telegram_bot_token", "magent_notify_push_token", + "magent_notify_push_user_key", "magent_notify_webhook_url", "jellyseerr_api_key", + "jellyfin_api_key", "sonarr_api_key", "radarr_api_key", "bazarr_api_key", + "prowlarr_api_key", "qbittorrent_password", + } +) + + +def _fernet_key() -> bytes: + configured = str(settings.settings_encryption_key or "").strip() + if configured: + try: + decoded = base64.urlsafe_b64decode(configured.encode("ascii")) + except Exception as exc: + raise RuntimeError("SETTINGS_ENCRYPTION_KEY must be a valid Fernet key") from exc + if len(decoded) != 32: + raise RuntimeError("SETTINGS_ENCRYPTION_KEY must decode to exactly 32 bytes") + return configured.encode("ascii") + jwt_secret = str(settings.jwt_secret or "").strip() + if len(jwt_secret) < 32 or jwt_secret == "change-me": + raise RuntimeError( + "SETTINGS_ENCRYPTION_KEY is required when JWT_SECRET is not a strong migration key" + ) + derived = hashlib.sha256(("magent-settings-v1:" + jwt_secret).encode("utf-8")).digest() + return base64.urlsafe_b64encode(derived) + + +def is_sensitive_setting(key: str) -> bool: + return str(key or "").strip().lower() in SENSITIVE_SETTING_KEYS + + +def validate_secret_storage_configuration() -> None: + """Validate the configured or JWT-derived Fernet key without touching stored data.""" + Fernet(_fernet_key()) + + +def encrypt_setting_value(key: str, value: Optional[str]) -> Optional[str]: + if value is None or not is_sensitive_setting(key): + return value + text = str(value) + if text.startswith(ENCRYPTED_PREFIX): + return text + token = Fernet(_fernet_key()).encrypt(text.encode("utf-8")).decode("ascii") + return ENCRYPTED_PREFIX + token + + +def decrypt_setting_value(key: str, value: Optional[str]) -> Optional[str]: + if value is None or not is_sensitive_setting(key): + return value + text = str(value) + if not text.startswith(ENCRYPTED_PREFIX): + return text + try: + return Fernet(_fernet_key()).decrypt( + text[len(ENCRYPTED_PREFIX) :].encode("ascii") + ).decode("utf-8") + except InvalidToken as exc: + raise RuntimeError( + f"Stored secret '{key}' cannot be decrypted with the configured key" + ) from exc diff --git a/backend/app/security.py b/backend/app/security.py index 1a2adbd..e3bc378 100644 --- a/backend/app/security.py +++ b/backend/app/security.py @@ -1,4 +1,5 @@ from datetime import datetime, timedelta, timezone +import uuid from typing import Any, Dict, Optional from passlib.context import CryptContext @@ -7,9 +8,15 @@ from jwt import InvalidTokenError from .config import settings -_pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto") +_pwd_context = CryptContext( + schemes=["argon2", "pbkdf2_sha256"], + deprecated=["pbkdf2_sha256"], + argon2__memory_cost=65536, + argon2__time_cost=3, + argon2__parallelism=4, +) _ALGORITHM = "HS256" -MIN_PASSWORD_LENGTH = 8 +MIN_PASSWORD_LENGTH = 12 PASSWORD_POLICY_MESSAGE = f"Password must be at least {MIN_PASSWORD_LENGTH} characters." @@ -18,7 +25,17 @@ def hash_password(password: str) -> str: def verify_password(plain_password: str, hashed_password: str) -> bool: - return _pwd_context.verify(plain_password, hashed_password) + try: + return _pwd_context.verify(plain_password, hashed_password) + except (TypeError, ValueError): + return False + + +def verify_and_update_password(plain_password: str, hashed_password: str) -> tuple[bool, Optional[str]]: + try: + return _pwd_context.verify_and_update(plain_password, hashed_password) + except (TypeError, ValueError): + return False, None def validate_password_policy(password: str) -> str: @@ -34,32 +51,58 @@ def _create_token( *, expires_at: datetime, token_type: str = "access", + auth_version: int = 1, ) -> str: + issued_at = datetime.now(timezone.utc) payload: Dict[str, Any] = { "sub": subject, "role": role, "typ": token_type, "exp": expires_at, + "iat": issued_at, + "jti": uuid.uuid4().hex, + "iss": settings.jwt_issuer, + "aud": settings.jwt_audience, + "ver": max(1, int(auth_version or 1)), } return jwt.encode(payload, settings.jwt_secret, algorithm=_ALGORITHM) -def create_access_token(subject: str, role: str, expires_minutes: Optional[int] = None) -> str: +def create_access_token( + subject: str, + role: str, + expires_minutes: Optional[int] = None, + *, + auth_version: int = 1, +) -> str: if not settings.jwt_secret: raise ValueError("JWT_SECRET is not configured") minutes = expires_minutes or settings.jwt_exp_minutes expires = datetime.now(timezone.utc) + timedelta(minutes=minutes) - return _create_token(subject, role, expires_at=expires, token_type="access") + return _create_token(subject, role, expires_at=expires, token_type="access", auth_version=auth_version) -def create_stream_token(subject: str, role: str, expires_seconds: int = 120) -> str: +def create_stream_token( + subject: str, + role: str, + expires_seconds: int = 120, + *, + auth_version: int = 1, +) -> str: expires = datetime.now(timezone.utc) + timedelta(seconds=max(30, int(expires_seconds or 120))) - return _create_token(subject, role, expires_at=expires, token_type="sse") + return _create_token(subject, role, expires_at=expires, token_type="sse", auth_version=auth_version) def decode_token(token: str) -> Dict[str, Any]: if not settings.jwt_secret: raise ValueError("JWT_SECRET is not configured") - return jwt.decode(token, settings.jwt_secret, algorithms=[_ALGORITHM]) + return jwt.decode( + token, + settings.jwt_secret, + algorithms=[_ALGORITHM], + audience=settings.jwt_audience, + issuer=settings.jwt_issuer, + options={"require": ["exp", "iat", "jti", "iss", "aud", "sub", "typ", "ver"]}, + ) class TokenError(Exception): diff --git a/backend/app/services/invite_email.py b/backend/app/services/invite_email.py index cec7d41..66c569e 100644 --- a/backend/app/services/invite_email.py +++ b/backend/app/services/invite_email.py @@ -1025,16 +1025,12 @@ def _send_email_sync(*, recipient_email: str, subject: str, body_text: str, body raise RuntimeError("SMTP email settings are incomplete.") local_hostname = _derive_mail_hostname(from_address=from_address) logger.info( - "smtp send started recipient=%s from=%s host=%s port=%s tls=%s ssl=%s auth=%s subject=%s ehlo=%s", - recipient_email, - from_address, + "smtp send started host=%s port=%s tls=%s ssl=%s auth=%s", host, port, use_tls, use_ssl, bool(username and password), - subject, - local_hostname, ) if delivery_warning: logger.warning("smtp delivery warning host=%s detail=%s", host, delivery_warning) @@ -1083,11 +1079,7 @@ def _send_email_sync(*, recipient_email: str, subject: str, body_text: str, body message=message, ) logger.info( - "smtp send accepted recipient=%s host=%s mode=ssl provider_message_id=%s provider_internal_id=%s", - recipient_email, - host, - receipt.get("provider_message_id"), - receipt.get("provider_internal_id"), + "smtp send accepted host=%s mode=ssl", host, ) return receipt @@ -1100,7 +1092,7 @@ def _send_email_sync(*, recipient_email: str, subject: str, body_text: str, body logger.debug("smtp starttls negotiated host=%s port=%s", host, port) if username and password: smtp.login(username, password) - logger.debug("smtp login succeeded host=%s username=%s", host, username) + logger.debug("smtp login succeeded host=%s", host) receipt = _send_via_smtp_session( smtp, from_address=from_address, @@ -1108,11 +1100,7 @@ def _send_email_sync(*, recipient_email: str, subject: str, body_text: str, body message=message, ) logger.info( - "smtp send accepted recipient=%s host=%s mode=plain provider_message_id=%s provider_internal_id=%s", - recipient_email, - host, - receipt.get("provider_message_id"), - receipt.get("provider_internal_id"), + "smtp send accepted host=%s mode=plain", host, ) return receipt @@ -1153,7 +1141,7 @@ async def send_templated_email( body_text=rendered["body_text"], body_html=rendered["body_html"], ) - logger.info("Email template sent: template=%s recipient=%s", template_key, resolved_email) + logger.info("Email template sent: template=%s", template_key) return { "recipient_email": resolved_email, "subject": rendered["subject"], @@ -1185,7 +1173,7 @@ async def send_generic_email( body_text=body_text.strip(), body_html=body_html.strip(), ) - logger.info("Generic email sent recipient=%s subject=%s", resolved_email, subject) + logger.info("Generic email sent") return { "recipient_email": resolved_email, "subject": subject.strip() or f"{env_settings.app_name} notification", @@ -1284,7 +1272,7 @@ async def send_test_email(recipient_email: Optional[str] = None) -> Dict[str, st body_text=body_text, body_html=body_html, ) - logger.info("SMTP test email sent: recipient=%s", resolved_email) + logger.info("SMTP test email sent") result = {"recipient_email": resolved_email, "subject": subject} result.update( { @@ -1383,9 +1371,8 @@ async def send_password_reset_email( body_html=body_html, ) logger.info( - "Password reset email sent: username=%s recipient=%s provider=%s", + "Password reset email sent: username=%s provider=%s", username, - resolved_email, auth_provider, ) result = { diff --git a/backend/app/services/password_reset.py b/backend/app/services/password_reset.py index 4b4844b..50ecd65 100644 --- a/backend/app/services/password_reset.py +++ b/backend/app/services/password_reset.py @@ -18,6 +18,7 @@ from ..db import ( mark_password_reset_token_used, set_user_auth_provider, set_user_password, + increment_user_auth_version, sync_jellyfin_password_state, ) from ..runtime import get_runtime_settings @@ -243,7 +244,7 @@ async def request_password_reset( delete_expired_password_reset_tokens() target = await _resolve_reset_target(identifier) if not target: - logger.info("password reset requested with no eligible match identifier=%s", identifier.strip().lower()[:256]) + logger.info("password reset requested with no eligible match") return {"status": "ok", "issued": False} token = secrets.token_urlsafe(32) @@ -324,6 +325,7 @@ async def apply_password_reset(token: str, new_password: str) -> Dict[str, Any]: raise ValueError("Password reset link is invalid or has expired.") await client.set_user_password(user_id, new_password) sync_jellyfin_password_state(username, new_password) + increment_user_auth_version(username) if str(stored_user.get("auth_provider") or "").strip().lower() != "jellyfin": set_user_auth_provider(username, "jellyfin") mark_password_reset_token_used(token) diff --git a/backend/requirements.txt b/backend/requirements.txt index 4914997..946e626 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -5,6 +5,8 @@ pydantic==2.12.5 pydantic-settings==2.14.2 PyJWT==2.13.0 passlib==1.7.4 +argon2-cffi==25.1.0 +cryptography==50.0.1 python-multipart==0.0.31 Pillow==12.3.0 prometheus-client==0.22.1 diff --git a/backend/tests/test_backend_quality.py b/backend/tests/test_backend_quality.py index 530a054..d5e573a 100644 --- a/backend/tests/test_backend_quality.py +++ b/backend/tests/test_backend_quality.py @@ -6,23 +6,25 @@ from unittest.mock import AsyncMock, call, patch import httpx from fastapi import HTTPException +from passlib.context import CryptContext from starlette.requests import Request from backend.app import db from backend.app.clients.base import _operation_error_message, _operation_result_message from backend.app.clients.jellyfin import _availability_message from backend.app.clients.qbittorrent import _torrent_result_message -from backend.app.auth import require_admin +from backend.app.auth import _load_current_user_from_token, require_admin from backend.app.config import settings from backend.app.network_security import request_trusts_forwarded_headers, validate_notification_target_url from backend.app.models import ActionOption, NormalizedState, RequestType, Snapshot, TimelineHop from backend.app.routers import auth as auth_router from backend.app.routers import admin as admin_router +from backend.app.routers import branding as branding_router from backend.app.routers import portal as portal_router from backend.app.routers import requests as requests_router from backend.app.routers import site as site_router from backend.app.routers import status as status_router -from backend.app.security import PASSWORD_POLICY_MESSAGE, validate_password_policy +from backend.app.security import PASSWORD_POLICY_MESSAGE, create_access_token, validate_password_policy from backend.app.services import password_reset from backend.app.services import issue_resolution from backend.app.services.operation_progress import ( @@ -71,21 +73,16 @@ class TempDatabaseMixin: self._tempdir = tempfile.TemporaryDirectory(ignore_cleanup_errors=True) self._original_sqlite_path = settings.sqlite_path self._original_journal_mode = getattr(settings, "sqlite_journal_mode", "DELETE") + self._original_settings_encryption_key = settings.settings_encryption_key settings.sqlite_path = os.path.join(self._tempdir.name, "test.db") settings.sqlite_journal_mode = "DELETE" - auth_router._LOGIN_ATTEMPTS_BY_IP.clear() - auth_router._LOGIN_ATTEMPTS_BY_USER.clear() - auth_router._RESET_ATTEMPTS_BY_IP.clear() - auth_router._RESET_ATTEMPTS_BY_IDENTIFIER.clear() + settings.settings_encryption_key = "bWFnZW50LXNlY3VyaXR5LXRlc3Qta2V5LTMyLWJ5dGU=" db.init_db() def tearDown(self) -> None: settings.sqlite_path = self._original_sqlite_path settings.sqlite_journal_mode = self._original_journal_mode - auth_router._LOGIN_ATTEMPTS_BY_IP.clear() - auth_router._LOGIN_ATTEMPTS_BY_USER.clear() - auth_router._RESET_ATTEMPTS_BY_IP.clear() - auth_router._RESET_ATTEMPTS_BY_IDENTIFIER.clear() + settings.settings_encryption_key = self._original_settings_encryption_key self._tempdir.cleanup() super_method = getattr(super(), "tearDown", None) if callable(super_method): @@ -98,7 +95,204 @@ class PasswordPolicyTests(unittest.TestCase): validate_password_policy("short") def test_validate_password_policy_trims_whitespace(self) -> None: - self.assertEqual(validate_password_policy(" password123 "), "password123") + self.assertEqual(validate_password_policy(" password1234 "), "password1234") + + +class SecurityHardeningTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase): + def setUp(self) -> None: + super().setUp() + self._jwt_secret = patch.object( + settings, "jwt_secret", "security-hardening-tests-secret-123456789" + ) + self._jwt_secret.start() + self.addCleanup(self._jwt_secret.stop) + + def test_sensitive_settings_are_encrypted_at_rest(self) -> None: + db.set_setting("jellyfin_api_key", "private-api-key") + + with db._connect() as conn: + stored = conn.execute( + "SELECT value FROM settings WHERE key = ?", ("jellyfin_api_key",) + ).fetchone()[0] + + self.assertTrue(stored.startswith("enc:v1:")) + self.assertNotIn("private-api-key", stored) + self.assertEqual(db.get_setting("jellyfin_api_key"), "private-api-key") + + def test_invites_are_hashed_and_rotation_invalidates_old_link(self) -> None: + created = db.create_signup_invite(code="TopSecretInvite42") + invite_id = int(created["id"]) + + with db._connect() as conn: + stored = conn.execute( + "SELECT code FROM signup_invites WHERE id = ?", (invite_id,) + ).fetchone()[0] + + self.assertTrue(stored.startswith("sha256:")) + self.assertNotIn("TOPSECRETINVITE42", stored.upper()) + self.assertFalse(db.get_signup_invite_by_id(invite_id)["code_available"]) + self.assertIsNotNone(db.get_signup_invite_by_code("TopSecretInvite42")) + + rotated = db.rotate_signup_invite_code(invite_id, "ReplacementInvite99") + self.assertTrue(rotated["code_available"]) + self.assertIsNone(db.get_signup_invite_by_code("TopSecretInvite42")) + self.assertIsNotNone(db.get_signup_invite_by_code("ReplacementInvite99")) + + def test_legacy_invites_and_plaintext_settings_migrate_in_place(self) -> None: + created = db.create_signup_invite(code="TemporaryInvite77") + with db._connect() as conn: + conn.execute( + "UPDATE signup_invites SET code = ?, code_hint = NULL WHERE id = ?", + ("Legacy-Code-77", int(created["id"])), + ) + conn.execute( + "INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES (?, ?, ?)", + ("radarr_api_key", "legacy-plaintext-key", "2026-09-17T00:00:00+00:00"), + ) + + db.init_db() + + migrated = db.get_signup_invite_by_code("Legacy-Code-77") + self.assertEqual(migrated["id"], created["id"]) + self.assertEqual(db.get_setting("radarr_api_key"), "legacy-plaintext-key") + with db._connect() as conn: + invite_code = conn.execute( + "SELECT code FROM signup_invites WHERE id = ?", (int(created["id"]),) + ).fetchone()[0] + stored_setting = conn.execute( + "SELECT value FROM settings WHERE key = 'radarr_api_key'" + ).fetchone()[0] + self.assertTrue(invite_code.startswith("sha256:")) + self.assertTrue(stored_setting.startswith("enc:v1:")) + + def test_legacy_password_hash_is_replaced_with_argon2(self) -> None: + password = "Example-password123!" + db.create_user("legacy", password) + legacy_hash = CryptContext(schemes=["pbkdf2_sha256"]).hash(password) + with db._connect() as conn: + conn.execute( + "UPDATE users SET password_hash = ? WHERE username = ?", + (legacy_hash, "legacy"), + ) + + self.assertIsNotNone(db.verify_user_password("legacy", password)) + self.assertTrue(db.get_user_by_username("legacy")["password_hash"].startswith("$argon2")) + + def test_auth_version_revokes_existing_token(self) -> None: + db.create_user("viewer", "Example-password123!") + user = db.get_user_by_username("viewer") + token = create_access_token( + "viewer", "user", auth_version=int(user["auth_version"]) + ) + self.assertEqual(_load_current_user_from_token(token)["username"], "viewer") + + db.increment_user_auth_version("viewer") + with self.assertRaises(HTTPException) as context: + _load_current_user_from_token(token) + self.assertEqual(context.exception.status_code, 401) + + async def test_request_mutations_require_owner_or_admin(self) -> None: + runtime = SimpleNamespace( + jellyseerr_base_url="http://seerr.test", jellyseerr_api_key="secret" + ) + client = SimpleNamespace( + configured=lambda: True, + get_request=AsyncMock( + return_value={"id": 42, "requestedBy": {"username": "owner"}} + ), + ) + with patch.object(requests_router, "JellyseerrClient", return_value=client): + with self.assertRaises(HTTPException) as context: + await requests_router._ensure_request_mutation_access( + runtime, 42, {"username": "someone-else", "role": "user"} + ) + self.assertEqual(context.exception.status_code, 403) + owned = await requests_router._ensure_request_mutation_access( + runtime, 42, {"username": "owner", "role": "user"} + ) + self.assertEqual(owned["id"], 42) + + self.assertIsNone( + await requests_router._ensure_request_mutation_access( + SimpleNamespace(), 42, {"username": "admin", "role": "admin"} + ) + ) + + def test_account_deletion_removes_or_anonymizes_personal_data(self) -> None: + db.create_user( + "viewer", "Example-password123!", email="viewer@example.test" + ) + user = db.get_user_by_username("viewer") + now = "2026-09-17T00:00:00+00:00" + db.upsert_request_cache( + 42, + 99, + "movie", + 2, + "Example", + 2026, + "viewer", + "viewer", + int(user["id"]), + now, + now, + '{"requestedBy":{"username":"viewer","email":"viewer@example.test"}}', + ) + with db._connect() as conn: + conn.execute( + "INSERT INTO snapshots (request_id, state, created_at, payload_json) VALUES (?, ?, ?, ?)", + ( + "42", + "available", + now, + '{"requestedBy":{"username":"viewer","email":"viewer@example.test"}}', + ), + ) + db.save_action("42", "created", "Created", "ok", "Created by viewer") + item = db.create_portal_item( + kind="issue", + title="Example", + description="Example", + created_by_username="viewer", + created_by_id=int(user["id"]), + ) + + result = db.delete_user_data_by_username("viewer") + + self.assertTrue(result["deleted"]) + self.assertIsNone(db.get_user_by_username("viewer")) + with db._connect() as conn: + request_row = conn.execute( + "SELECT requested_by, requested_by_id, payload_json FROM requests_cache WHERE request_id = 42" + ).fetchone() + snapshot_json = conn.execute( + "SELECT payload_json FROM snapshots WHERE request_id = '42'" + ).fetchone()[0] + action_message = conn.execute( + "SELECT message FROM actions WHERE request_id = '42'" + ).fetchone()[0] + portal_owner = conn.execute( + "SELECT created_by_username, created_by_id FROM portal_items WHERE id = ?", + (item["id"],), + ).fetchone() + self.assertEqual(request_row[0], "Deleted user") + self.assertIsNone(request_row[1]) + self.assertNotIn("viewer", request_row[2].lower()) + self.assertNotIn("viewer", snapshot_json.lower()) + self.assertNotIn("viewer", action_message.lower()) + self.assertTrue(portal_owner[0].startswith("deleted-user-")) + self.assertIsNone(portal_owner[1]) + + async def test_branding_upload_rejects_oversized_images_before_decode(self) -> None: + upload = SimpleNamespace( + filename="logo.png", + content_type="image/png", + read=AsyncMock(return_value=b"x" * (5 * 1024 * 1024 + 1)), + ) + with self.assertRaises(HTTPException) as context: + await branding_router.save_branding_image(upload) + self.assertEqual(context.exception.status_code, 413) + upload.read.assert_awaited_once_with(5 * 1024 * 1024 + 1) class NetworkSecurityTests(unittest.TestCase): @@ -1208,6 +1402,13 @@ class CollectorManualDownloadTests(unittest.IsolatedAsyncioTestCase): secret = patch.object(settings, 'jwt_secret', 'manual-release-tests-secret-1234567890123456') secret.start() self.addCleanup(secret.stop) + access = patch.object( + requests_router, + "_ensure_request_mutation_access", + new=AsyncMock(return_value=None), + ) + access.start() + self.addCleanup(access.stop) def selection(self, payload, request_id, source): payload['selectionToken'] = requests_router.manual_releases.issue_selection( @@ -1628,6 +1829,16 @@ class AuthFlowTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase): class MediaReplacementTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase): + def setUp(self) -> None: + super().setUp() + access = patch.object( + requests_router, + "_ensure_request_mutation_access", + new=AsyncMock(return_value=None), + ) + access.start() + self.addCleanup(access.stop) + def test_failed_repair_marks_linked_issue_as_blocked(self) -> None: issue = {"id": 12, "status": "in_progress"} with ( @@ -2072,6 +2283,11 @@ class MediaReplacementTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase) patch.object(requests_router, "get_runtime_settings", return_value=runtime), patch.object(requests_router, "build_snapshot", new=AsyncMock(return_value=snapshot)), patch.object(requests_router, "BazarrClient", return_value=bazarr), + patch.object( + requests_router, + "_ensure_request_mutation_access", + new=AsyncMock(return_value=None), + ), patch.object(requests_router, "save_action"), patch.object(requests_router, "get_portal_item", return_value={ "id": 12, @@ -2184,28 +2400,28 @@ class InviteOperationalStateTests(TempDatabaseMixin, unittest.IsolatedAsyncioTes async def test_invite_list_reports_automatic_operational_states(self) -> None: ready = db.create_signup_invite(code="READY", recipient_email="ready@example.com") - db.create_signup_invite(code="DISABLED", enabled=False, recipient_email="off@example.com") + disabled = db.create_signup_invite(code="DISABLED", enabled=False, recipient_email="off@example.com") used = db.create_signup_invite(code="USED", max_uses=1, recipient_email="used@example.com") db.increment_signup_invite_use(int(used["id"])) - db.create_signup_invite( + expired = db.create_signup_invite( code="EXPIRED", expires_at="2000-01-01T00:00:00+00:00", recipient_email="expired@example.com", ) - db.create_signup_invite( + no_profile = db.create_signup_invite( code="NO-PROFILE", profile_id=999, recipient_email="profile@example.com", ) payload = await admin_router.get_invites() - states = {invite["code"]: invite["operational_state"] for invite in payload["invites"]} + states = {invite["id"]: invite["operational_state"] for invite in payload["invites"]} - self.assertEqual(states[ready["code"]], "ready") - self.assertEqual(states["DISABLED"], "disabled") - self.assertEqual(states["USED"], "exhausted") - self.assertEqual(states["EXPIRED"], "expired") - self.assertEqual(states["NO-PROFILE"], "profile_unavailable") + self.assertEqual(states[ready["id"]], "ready") + self.assertEqual(states[disabled["id"]], "disabled") + self.assertEqual(states[used["id"]], "exhausted") + self.assertEqual(states[expired["id"]], "expired") + self.assertEqual(states[no_profile["id"]], "profile_unavailable") self.assertEqual(payload["summary"]["total"], 5) self.assertEqual(payload["summary"]["ready"], 1) self.assertEqual(payload["summary"]["attention"], 4) diff --git a/backend/tests/test_feature_access.py b/backend/tests/test_feature_access.py index f9991a3..bf9efe6 100644 --- a/backend/tests/test_feature_access.py +++ b/backend/tests/test_feature_access.py @@ -1,5 +1,5 @@ import unittest -from unittest.mock import patch +from unittest.mock import AsyncMock, patch from backend.app.config import settings from fastapi import FastAPI from fastapi.testclient import TestClient @@ -16,6 +16,13 @@ class FeatureAccessTests(TempDatabaseMixin, unittest.TestCase): secret = patch.object(settings, "jwt_secret", "feature-access-tests-only-secret-123456789") secret.start() self.addCleanup(secret.stop) + access = patch.object( + requests, + "_ensure_request_mutation_access", + new=AsyncMock(return_value=None), + ) + access.start() + self.addCleanup(access.stop) db.create_user('feature-viewer', 'Example-password123!', role='user') db.create_user('feature-admin', 'Example-password123!', role='admin') self.user = db.get_user_by_username('feature-viewer') diff --git a/backend/tests/test_manual_selection.py b/backend/tests/test_manual_selection.py index 0a292ad..ae0bf7f 100644 --- a/backend/tests/test_manual_selection.py +++ b/backend/tests/test_manual_selection.py @@ -61,6 +61,15 @@ class ManualPermissionTests(TempDatabaseMixin, unittest.TestCase): class ManualEpisodeSearchTests(unittest.IsolatedAsyncioTestCase): + def setUp(self): + access = patch.object( + requests, + '_ensure_request_mutation_access', + new=AsyncMock(return_value=None), + ) + access.start() + self.addCleanup(access.stop) + async def test_episode_batch_is_bounded_and_exposes_next_page(self): episodes = [{'id': i, 'seasonNumber': 1, 'monitored': True, 'hasFile': False} for i in range(1, 26)] episodes += [{'id': 26, 'seasonNumber': 1, 'monitored': True, 'hasFile': True}] diff --git a/docker-compose.beta.yml b/docker-compose.beta.yml index af85e24..019c116 100644 --- a/docker-compose.beta.yml +++ b/docker-compose.beta.yml @@ -15,6 +15,8 @@ services: AUTH_COOKIE_NAME: magent_beta_auth AUTH_STATE_COOKIE_NAME: magent_beta_logged_in AUTH_COOKIE_DOMAIN: beta.grizzlyflix.co.nz + AUTH_COOKIE_SECURE: "true" + AUTH_COOKIE_SAMESITE: strict SQLITE_PATH: /app/data/magent.db LOG_FILE: /app/data/magent.log SITE_BANNER_ENABLED: "true" @@ -26,3 +28,10 @@ services: volumes: - ./data:/app/data restart: unless-stopped + read_only: true + cap_drop: ["ALL"] + security_opt: ["no-new-privileges:true"] + init: true + tmpfs: + - /tmp:rw,noexec,nosuid,size=64m,uid=1000,gid=1000 + - /app/frontend/.next/cache:rw,noexec,nosuid,size=128m,uid=1000,gid=1000 diff --git a/docker-compose.hub.yml b/docker-compose.hub.yml index f3c695c..5068ee3 100644 --- a/docker-compose.hub.yml +++ b/docker-compose.hub.yml @@ -5,6 +5,13 @@ services: - ./.env ports: - "3000:3000" - - "8000:8000" + - "127.0.0.1:8000:8000" volumes: - ./data:/app/data + read_only: true + cap_drop: ["ALL"] + security_opt: ["no-new-privileges:true"] + init: true + tmpfs: + - /tmp:rw,noexec,nosuid,size=64m,uid=1000,gid=1000 + - /app/frontend/.next/cache:rw,noexec,nosuid,size=128m,uid=1000,gid=1000 diff --git a/docker-compose.production.yml b/docker-compose.production.yml index f4eee3d..e048295 100644 --- a/docker-compose.production.yml +++ b/docker-compose.production.yml @@ -5,9 +5,19 @@ services: build: . env_file: - ./.env + environment: + AUTH_COOKIE_SECURE: "true" + AUTH_COOKIE_SAMESITE: strict ports: - "10.30.1.32:3200:3000" - "127.0.0.1:8200:8000" volumes: - ./data:/app/data restart: unless-stopped + read_only: true + cap_drop: ["ALL"] + security_opt: ["no-new-privileges:true"] + init: true + tmpfs: + - /tmp:rw,noexec,nosuid,size=64m,uid=1000,gid=1000 + - /app/frontend/.next/cache:rw,noexec,nosuid,size=128m,uid=1000,gid=1000 diff --git a/docker-compose.yml b/docker-compose.yml index ebbc613..9da3d77 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,6 +7,13 @@ services: - ./.env ports: - "3000:3000" - - "8000:8000" + - "127.0.0.1:8000:8000" volumes: - ./data:/app/data + read_only: true + cap_drop: ["ALL"] + security_opt: ["no-new-privileges:true"] + init: true + tmpfs: + - /tmp:rw,noexec,nosuid,size=64m,uid=1000,gid=1000 + - /app/frontend/.next/cache:rw,noexec,nosuid,size=128m,uid=1000,gid=1000 diff --git a/frontend/app/admin/invites/page.tsx b/frontend/app/admin/invites/page.tsx index 1e18d39..9f53c86 100644 --- a/frontend/app/admin/invites/page.tsx +++ b/frontend/app/admin/invites/page.tsx @@ -36,6 +36,7 @@ type Profile = { type Invite = { id: number code: string + code_available?: boolean label?: string | null description?: string | null profile_id?: number | null @@ -501,17 +502,30 @@ export default function AdminInviteManagementPage() { } const copyInviteLink = async (invite: Invite) => { - const url = `${signupBaseUrl}?code=${encodeURIComponent(invite.code)}` try { + let usableInvite = invite + if (!invite.code_available) { + const response = await authFetch(`${getApiBase()}/admin/invites/${invite.id}/rotate`, { + method: 'POST', + }) + if (!response.ok) { + if (handleAuthResponse(response)) return + throw new Error((await response.text()) || 'Could not generate a replacement link.') + } + const data = await response.json() + usableInvite = data.invite as Invite + setInvites((current) => current.map((item) => item.id === invite.id ? usableInvite : item)) + } + const url = `${signupBaseUrl}?code=${encodeURIComponent(usableInvite.code)}` if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(url) - setStatus(`Copied invite link for ${invite.code}.`) + setStatus(`Copied the invite link. Keep it safe; Magent will not display it again after this page reloads.`) } else { window.prompt('Copy invite link', url) } } catch (err) { console.error(err) - window.prompt('Copy invite link', url) + setError(err instanceof Error ? err.message : 'Could not generate or copy the invite link.') } } @@ -1666,7 +1680,7 @@ export default function AdminInviteManagementPage() {
)}} + {invites.length === 0 ?
You have not created any invites yet.
:
{invites.map((invite) =>
{invite.label || 'Unnamed invite'}{invite.code}{invite.is_usable ? 'Ready' : 'Unavailable'}
{invite.description &&

{invite.description}

}
Delivery: {invite.recipient_email || 'Manual link'}Uses: {invite.use_count}{typeof invite.max_uses === 'number' ? ` / ${invite.max_uses}` : ''}Expires: {formatDate(invite.expires_at)}Created: {formatDate(invite.created_at)}
)}
} )} diff --git a/frontend/next.config.js b/frontend/next.config.js index 04535d5..10cd138 100644 --- a/frontend/next.config.js +++ b/frontend/next.config.js @@ -2,7 +2,27 @@ const backendUrl = process.env.BACKEND_INTERNAL_URL || 'http://backend:8000' /** @type {import('next').NextConfig} */ const nextConfig = { + poweredByHeader: false, + compress: true, experimental: { proxyTimeout: 180000 }, + async headers() { + return [ + { + source: '/:path*', + headers: [ + { key: 'Strict-Transport-Security', value: 'max-age=31536000; includeSubDomains' }, + { key: 'X-Content-Type-Options', value: 'nosniff' }, + { key: 'X-Frame-Options', value: 'DENY' }, + { key: 'Referrer-Policy', value: 'no-referrer' }, + { key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' }, + ], + }, + { + source: '/login', + headers: [{ key: 'Cache-Control', value: 'private, no-store, max-age=0' }], + }, + ] + }, async rewrites() { return [ { diff --git a/frontend/proxy.ts b/frontend/proxy.ts new file mode 100644 index 0000000..82dab84 --- /dev/null +++ b/frontend/proxy.ts @@ -0,0 +1,40 @@ +import { NextRequest, NextResponse } from 'next/server' + +export function proxy(request: NextRequest) { + const nonce = Buffer.from(crypto.randomUUID()).toString('base64') + const developmentEval = process.env.NODE_ENV === 'development' ? " 'unsafe-eval'" : '' + const csp = [ + "default-src 'self'", + "base-uri 'self'", + "object-src 'none'", + "frame-ancestors 'none'", + "form-action 'self'", + `script-src 'self' 'nonce-${nonce}' 'strict-dynamic'${developmentEval}`, + "style-src 'self' 'unsafe-inline'", + "img-src 'self' data: blob: https:", + "font-src 'self' data:", + "connect-src 'self'", + "worker-src 'self' blob:", + "manifest-src 'self'", + "upgrade-insecure-requests", + ].join('; ') + + const requestHeaders = new Headers(request.headers) + requestHeaders.set('x-nonce', nonce) + requestHeaders.set('Content-Security-Policy', csp) + const response = NextResponse.next({ request: { headers: requestHeaders } }) + response.headers.set('Content-Security-Policy', csp) + return response +} + +export const config = { + matcher: [ + { + source: '/((?!api|_next/static|_next/image|favicon.ico|branding/).*)', + missing: [ + { type: 'header', key: 'next-router-prefetch' }, + { type: 'header', key: 'purpose', value: 'prefetch' }, + ], + }, + ], +} diff --git a/scripts/deploy_ams_dev01.sh b/scripts/deploy_ams_dev01.sh index ea52e0f..33ff053 100644 --- a/scripts/deploy_ams_dev01.sh +++ b/scripts/deploy_ams_dev01.sh @@ -7,7 +7,7 @@ cd "$repo_root" deploy_host="${DEPLOY_HOST:-AMS-DEV01}" deploy_user="${DEPLOY_USER:-zak}" deploy_path="${DEPLOY_PATH:-/home/${deploy_user}/magent}" -ssh_opts="${DEPLOY_SSH_OPTS:-"-o StrictHostKeyChecking=accept-new"}" +ssh_opts="${DEPLOY_SSH_OPTS:-"-o StrictHostKeyChecking=yes"}" timestamp="$(date -u +%Y%m%dT%H%M%SZ)" remote="${deploy_user}@${deploy_host}" @@ -16,9 +16,12 @@ echo "Deploying tracked repository contents to ${remote}:${deploy_path}" git archive --format=tar HEAD | ssh ${ssh_opts} "${remote}" " set -e + umask 077 mkdir -p '${deploy_path}' + chmod 700 '${deploy_path}' backup_root=\"\${HOME}/magent-backups/${timestamp}\" mkdir -p \"\${backup_root}\" + chmod 700 \"\${backup_root}\" cd '${deploy_path}' for path in backend frontend docker-compose.yml docker-compose.hub.yml Dockerfile README.md docker scripts .build_number .gitattributes .gitignore; do if [ -e \"\$path\" ]; then @@ -26,7 +29,20 @@ git archive --format=tar HEAD | ssh ${ssh_opts} "${remote}" " fi done tar -xf - -C '${deploy_path}' - docker compose up -d --build + if [ -f '${deploy_path}/.env' ]; then + chmod 600 '${deploy_path}/.env' + fi + mkdir -p '${deploy_path}/data' + chmod 700 '${deploy_path}/data' + docker compose build + if ! grep -Eq '^[[:space:]]*SETTINGS_ENCRYPTION_KEY=' .env; then + settings_key=\"\$(docker compose run --rm --no-deps --entrypoint python magent -c 'from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())')\" + printf '\nSETTINGS_ENCRYPTION_KEY=%s\n' \"\${settings_key}\" >> .env + chmod 600 .env + fi + docker compose run --rm --no-deps --entrypoint python magent -c \"from app.config import settings; from app.secret_storage import validate_secret_storage_configuration; assert len(str(settings.jwt_secret or '').strip()) >= 32, 'JWT_SECRET must contain at least 32 characters'; validate_secret_storage_configuration()\" + docker compose run --rm --user 0 magent chown -R 1000:1000 /app/data + docker compose up -d " echo "Running remote smoke checks" diff --git a/scripts/deploy_beta_ams_dev01.sh b/scripts/deploy_beta_ams_dev01.sh index 3713bb3..bd6389d 100755 --- a/scripts/deploy_beta_ams_dev01.sh +++ b/scripts/deploy_beta_ams_dev01.sh @@ -9,7 +9,7 @@ deploy_user="${DEPLOY_USER:-zak}" prod_path="${PROD_DEPLOY_PATH:-/home/${deploy_user}/magent}" deploy_path="${BETA_DEPLOY_PATH:-/home/${deploy_user}/magent-beta}" beta_frontend_bind="${BETA_FRONTEND_BIND:-10.30.1.32}" -ssh_opts="${DEPLOY_SSH_OPTS:-"-o StrictHostKeyChecking=accept-new"}" +ssh_opts="${DEPLOY_SSH_OPTS:-"-o StrictHostKeyChecking=yes"}" timestamp="$(date -u +%Y%m%dT%H%M%SZ)" remote="${deploy_user}@${deploy_host}" @@ -18,9 +18,12 @@ echo "Deploying tracked beta repository contents to ${remote}:${deploy_path}" git archive --format=tar HEAD | ssh ${ssh_opts} "${remote}" " set -e + umask 077 mkdir -p '${deploy_path}' + chmod 700 '${deploy_path}' backup_root=\"\${HOME}/magent-beta-backups/${timestamp}\" mkdir -p \"\${backup_root}\" + chmod 700 \"\${backup_root}\" cd '${deploy_path}' for path in backend frontend docker-compose.yml docker-compose.hub.yml docker-compose.beta.yml Dockerfile README.md docker scripts .build_number .gitattributes .gitignore; do if [ -e \"\$path\" ]; then @@ -32,14 +35,25 @@ git archive --format=tar HEAD | ssh ${ssh_opts} "${remote}" " if [ ! -f '${deploy_path}/.env' ] && [ -f '${prod_path}/.env' ]; then cp '${prod_path}/.env' '${deploy_path}/.env' fi + if [ -f '${deploy_path}/.env' ]; then + chmod 600 '${deploy_path}/.env' + fi mkdir -p '${deploy_path}/data' + chmod 700 '${deploy_path}/data' if [ ! -f '${deploy_path}/data/magent.db' ] && [ -d '${prod_path}/data' ]; then cp -a '${prod_path}/data/.' '${deploy_path}/data/' fi cd '${deploy_path}' docker compose -p magent-beta -f docker-compose.beta.yml build + if ! grep -Eq '^[[:space:]]*SETTINGS_ENCRYPTION_KEY=' .env; then + settings_key=\"\$(docker compose -p magent-beta -f docker-compose.beta.yml run --rm --no-deps --entrypoint python magent -c 'from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())')\" + printf '\nSETTINGS_ENCRYPTION_KEY=%s\n' \"\${settings_key}\" >> .env + chmod 600 .env + fi + docker compose -p magent-beta -f docker-compose.beta.yml run --rm --no-deps --entrypoint python magent -c \"from app.config import settings; from app.secret_storage import validate_secret_storage_configuration; assert len(str(settings.jwt_secret or '').strip()) >= 32, 'JWT_SECRET must contain at least 32 characters'; validate_secret_storage_configuration()\" + docker compose -p magent-beta -f docker-compose.beta.yml run --rm --user 0 magent chown -R 1000:1000 /app/data docker compose -p magent-beta -f docker-compose.beta.yml up -d " diff --git a/scripts/prepare_production_settings.py b/scripts/prepare_production_settings.py index d261bcb..487b06a 100644 --- a/scripts/prepare_production_settings.py +++ b/scripts/prepare_production_settings.py @@ -9,6 +9,8 @@ from pathlib import Path import secrets import sys +from cryptography.fernet import Fernet + from app.runtime import get_runtime_settings @@ -30,6 +32,7 @@ def prepare(destination: Path) -> None: password = secrets.token_urlsafe(30) values.update( APP_NAME='Magent', JWT_SECRET=secrets.token_urlsafe(48), + SETTINGS_ENCRYPTION_KEY=Fernet.generate_key().decode('ascii'), ADMIN_USERNAME='admin', ADMIN_PASSWORD=password, AUTH_COOKIE_SECURE=True, AUTH_COOKIE_DOMAIN='magent.grizzlyflix.co.nz', AUTH_COOKIE_NAME='magent_auth', AUTH_STATE_COOKIE_NAME='magent_logged_in',