From 69f12b051979da0bdef46f452eeabcfa0ba82047 Mon Sep 17 00:00:00 2001 From: Jonathan Kirkwood Date: Wed, 1 Jul 2026 19:09:27 -0500 Subject: [PATCH] 0.2.26: security hardening from audit (P2/P3 fixes) Address the security-auditor findings the user selected (items 2, 3, 4): Default admin credentials (P2): remove the fixed `Ten31` default. First boot now generates a strong random admin password (secrets.token_urlsafe), records it 0600 at /data/.admin-password, and surfaces it once via a new "Show Initial Admin Password" StartOS action (CLI `show-admin-password`). The stored password is cleared when the admin is reset (CLI reset-password) or self-changes it (change-password endpoint). Login hardening (P2): add a per-IP in-memory sliding-window rate limiter (10 failures / 5 min -> 429 + Retry-After) in ratelimit.py; run a dummy argon2 verify when the user is unknown so timing can't enumerate usernames; keep a single generic 401 for unknown-user and wrong-password. Hardening (P3): server process now runs unprivileged -- Dockerfile adds uid 10001 appuser; start.sh (still root) chowns the mounted /data then drops via `setpriv` before exec'ing uvicorn. Spreadsheet imports are size-capped via storage.read_capped (413 past MAX_UPLOAD_SIZE) in the schedule, capital preview, and batch paths. batch_import no longer returns raw exception text (generic per-file messages). Verified in the packed amd64 container: PID1 uvicorn runs as uid 10001, /data owned 10001 with 0600 secrets; generated admin password retrievable via CLI and logs in (200); 11th bad login -> 429; admin reset clears the stored password. Tests: test_auth_hardening.py (4). Full suite 21 passed; frontend tsc + StartOS bundle clean. Co-Authored-By: Claude Opus 4.8 --- backend/ten31portal/cli.py | 32 ++++++++++ backend/ten31portal/config.py | 8 ++- backend/ten31portal/ratelimit.py | 47 +++++++++++++++ backend/ten31portal/routers/auth_router.py | 38 +++++++++++- .../routers/capital_import_router.py | 17 ++++-- backend/ten31portal/routers/import_router.py | 6 +- backend/ten31portal/storage.py | 17 ++++++ backend/tests/conftest.py | 10 ++++ backend/tests/test_auth_hardening.py | 59 +++++++++++++++++++ deploy/Dockerfile | 5 ++ deploy/package.json | 2 +- deploy/start.sh | 45 ++++++++++---- deploy/startos/actions/index.ts | 39 ++++++++++++ deploy/startos/install/versions/index.ts | 5 +- deploy/startos/install/versions/v_0_2_26.ts | 13 ++++ frontend/public/sw.js | 2 +- frontend/src/version.ts | 2 +- 17 files changed, 324 insertions(+), 23 deletions(-) create mode 100644 backend/ten31portal/ratelimit.py create mode 100644 backend/tests/test_auth_hardening.py create mode 100644 deploy/startos/install/versions/v_0_2_26.ts diff --git a/backend/ten31portal/cli.py b/backend/ten31portal/cli.py index 8e201d3..98087f6 100644 --- a/backend/ten31portal/cli.py +++ b/backend/ten31portal/cli.py @@ -1,10 +1,12 @@ """CLI commands for Ten31Portal (also driven by the StartOS service Actions).""" import argparse +import os import sys from sqlmodel import Session, select +from ten31portal import config from ten31portal.auth import hash_password from ten31portal.database import engine from ten31portal.db_init import run_migrations @@ -56,6 +58,14 @@ def create_user(args: argparse.Namespace) -> None: print(f"Created user: {user.name} ({user.username}) with role {user.role.value}") +def _clear_admin_password_file() -> None: + """Remove the recorded initial admin password (it is no longer valid once changed).""" + try: + os.remove(config.ADMIN_PASSWORD_FILE) + except OSError: + pass + + def reset_password(args: argparse.Namespace) -> None: """Reset a user's password (and re-enable their login).""" run_migrations() @@ -68,11 +78,29 @@ def reset_password(args: argparse.Namespace) -> None: sys.exit(1) user.password_hash = hash_password(args.password) user.login_enabled = True + is_admin = user.is_service_admin session.add(user) session.commit() + # Resetting the built-in admin invalidates the initial password recorded on first boot. + if is_admin: + _clear_admin_password_file() print(f"Password reset for {user.name} ({user.username}).") +def show_admin_password(args: argparse.Namespace) -> None: + """Print the randomly-generated initial admin password recorded on first boot.""" + path = config.ADMIN_PASSWORD_FILE + if not os.path.exists(path): + print( + "No stored initial password. It was either set explicitly at install time, or the " + "admin password has already been changed. Use Reset Password to set a new one." + ) + return + with open(path) as f: + pw = f.read().strip() + print(pw if pw else "(the recorded initial password is empty)") + + def list_users(args: argparse.Namespace) -> None: """Print all user accounts.""" run_migrations() @@ -240,6 +268,8 @@ def main() -> None: sub.add_parser("list-users", help="List all user accounts") + sub.add_parser("show-admin-password", help="Show the initial admin password from first boot") + delete = sub.add_parser("delete-user", help="Delete a user (not the Service Admin)") delete.add_argument("--username", required=False, default=None) delete.add_argument("--email", required=False, default=None) @@ -261,6 +291,8 @@ def main() -> None: reset_password(args) elif args.command == "list-users": list_users(args) + elif args.command == "show-admin-password": + show_admin_password(args) elif args.command == "delete-user": delete_user(args) elif args.command == "dedupe-holdings": diff --git a/backend/ten31portal/config.py b/backend/ten31portal/config.py index 4c70d07..9643d4e 100644 --- a/backend/ten31portal/config.py +++ b/backend/ten31portal/config.py @@ -7,5 +7,11 @@ DB_PATH: str = os.getenv("TEN31_DB_PATH", "/data/ten31portal/portal.db") SESSION_SECRET: str = os.getenv("TEN31_SESSION_SECRET", "change-me-in-production") DOCS_DIR: str = os.getenv("TEN31_DOCS_DIR", "/data/ten31portal/documents") # Cap on a single uploaded document. The docs dir shares the data volume with the DB, so an -# unbounded upload could fill the disk and take the portal down. Default 50 MB. +# unbounded upload could fill the disk and take the portal down. Default 50 MB. The same cap +# also bounds spreadsheet imports (which must be read fully into memory to parse). MAX_UPLOAD_SIZE: int = int(os.getenv("TEN31_MAX_UPLOAD_SIZE", str(50 * 1024 * 1024))) + +# Where start.sh records the randomly-generated initial admin password on first boot, so the +# operator can retrieve it once (via the "Show Initial Admin Password" service action) and then +# change it. Lives next to the DB on the 0600 data volume; removed once the password is reset. +ADMIN_PASSWORD_FILE: str = os.path.join(os.path.dirname(DB_PATH) or ".", ".admin-password") diff --git a/backend/ten31portal/ratelimit.py b/backend/ten31portal/ratelimit.py new file mode 100644 index 0000000..5dee553 --- /dev/null +++ b/backend/ten31portal/ratelimit.py @@ -0,0 +1,47 @@ +"""A tiny in-process sliding-window rate limiter for the login endpoint. + +The portal runs as a single uvicorn process, so an in-memory counter is enough to blunt +online password guessing without adding a dependency or a shared store. It is keyed by client +IP; only failed attempts are counted, and a successful login clears the key. This deliberately +does NOT lock accounts (which would let anyone lock out a user by name) — it throttles the +source of the guessing instead. +""" + +import time +from collections import defaultdict, deque +from threading import Lock + + +class SlidingWindowLimiter: + def __init__(self, max_attempts: int, window_seconds: float): + self.max_attempts = max_attempts + self.window = window_seconds + self._hits: dict[str, deque] = defaultdict(deque) + self._lock = Lock() + + def _prune(self, key: str, now: float) -> deque: + dq = self._hits[key] + cutoff = now - self.window + while dq and dq[0] <= cutoff: + dq.popleft() + if not dq: + self._hits.pop(key, None) + return dq + + def retry_after(self, key: str) -> float: + """Seconds until `key` may try again, or 0.0 if it is under the limit right now.""" + now = time.monotonic() + with self._lock: + dq = self._prune(key, now) + if len(dq) < self.max_attempts: + return 0.0 + return self.window - (now - dq[0]) + + def record_failure(self, key: str) -> None: + now = time.monotonic() + with self._lock: + self._hits[key].append(now) + + def reset(self, key: str) -> None: + with self._lock: + self._hits.pop(key, None) diff --git a/backend/ten31portal/routers/auth_router.py b/backend/ten31portal/routers/auth_router.py index f6c08a5..084b9dd 100644 --- a/backend/ten31portal/routers/auth_router.py +++ b/backend/ten31portal/routers/auth_router.py @@ -1,15 +1,27 @@ """Authentication endpoints.""" +import os +import secrets + from fastapi import APIRouter, Depends, HTTPException, Request from sqlmodel import Session, select +from ten31portal import config from ten31portal.auth import get_current_user, hash_password, verify_password from ten31portal.database import get_session from ten31portal.models import User +from ten31portal.ratelimit import SlidingWindowLimiter from ten31portal.schemas import ChangePasswordRequest, LoginRequest, UserResponse router = APIRouter(prefix="/api/auth", tags=["auth"]) +# Throttle password guessing per client IP: at most 10 failed attempts per 5 minutes. +_login_limiter = SlidingWindowLimiter(max_attempts=10, window_seconds=300) + +# A throwaway hash verified when no user matches, so a missing account costs the same argon2 +# time as a real one — otherwise the timing difference reveals which usernames exist. +_DUMMY_HASH = hash_password(secrets.token_urlsafe(16)) + @router.post("/login") def login( @@ -17,13 +29,30 @@ def login( request: Request, session: Session = Depends(get_session), ) -> UserResponse: + client_ip = request.client.host if request.client else "unknown" + retry_after = _login_limiter.retry_after(client_ip) + if retry_after > 0: + raise HTTPException( + status_code=429, + detail="Too many login attempts. Please wait a moment and try again.", + headers={"Retry-After": str(int(retry_after) + 1)}, + ) + # Accept either a username or an email in the login field. handle = body.login.strip() user = session.exec(select(User).where(User.username == handle)).first() if user is None: user = session.exec(select(User).where(User.email == handle)).first() - if user is None or not verify_password(body.password, user.password_hash): + # Always run a verify (dummy hash when the user is unknown) so success/failure take the + # same time, and give one generic message so neither branch leaks whether the user exists. + if user is None: + verify_password(body.password, _DUMMY_HASH) + _login_limiter.record_failure(client_ip) raise HTTPException(status_code=401, detail="Invalid username or password") + if not verify_password(body.password, user.password_hash): + _login_limiter.record_failure(client_ip) + raise HTTPException(status_code=401, detail="Invalid username or password") + # Past this point the password was correct, so these messages don't aid guessing. if user.primary_account_id is not None: raise HTTPException( status_code=401, @@ -33,6 +62,7 @@ def login( raise HTTPException(status_code=401, detail="This account does not have a login yet.") if not user.is_active: raise HTTPException(status_code=401, detail="Account disabled") + _login_limiter.reset(client_ip) request.session["user_id"] = user.id return UserResponse.model_validate(user, from_attributes=True) @@ -62,4 +92,10 @@ def change_password( user.password_hash = hash_password(body.new_password) session.add(user) session.commit() + # Once the built-in admin sets their own password, the initial one from first boot is stale. + if user.is_service_admin: + try: + os.remove(config.ADMIN_PASSWORD_FILE) + except OSError: + pass return {"status": "ok"} diff --git a/backend/ten31portal/routers/capital_import_router.py b/backend/ten31portal/routers/capital_import_router.py index b869755..5bb37fd 100644 --- a/backend/ten31portal/routers/capital_import_router.py +++ b/backend/ten31portal/routers/capital_import_router.py @@ -19,6 +19,7 @@ import openpyxl from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile from sqlmodel import Session, select +from ten31portal import storage from ten31portal.audit import record_audit from ten31portal.auth import hash_password, require_internal_admin from ten31portal.database import get_session @@ -195,7 +196,11 @@ def preview_import( if entity_id is not None and session.get(Entity, entity_id) is None: raise HTTPException(status_code=404, detail="Entity not found") - wb = _open_workbook(file.file.read(), password) + try: + file_bytes = storage.read_capped(file) + except storage.UploadTooLarge as exc: + raise HTTPException(status_code=413, detail=str(exc)) + wb = _open_workbook(file_bytes, password) investors = session.exec(select(User).where(User.role == UserRole.investor)).all() by_name = {u.name.strip().lower(): u for u in investors} @@ -388,7 +393,7 @@ def batch_import( fname = upload.filename or "(unnamed)" res = BatchCapitalFileResult(filename=fname) try: - wb = _open_workbook(upload.file.read(), password) + wb = _open_workbook(storage.read_capped(upload), password) if "ALLOC SI" not in wb.sheetnames: raise HTTPException(status_code=422, detail="No ALLOC SI tab found in this workbook.") as_of, roster = _parse_alloc_si(wb) @@ -452,11 +457,15 @@ def batch_import( session.commit() total_statements += res.statements_written except HTTPException as e: + # Our own controlled messages (bad password, no ALLOC SI, no date) are safe to show. session.rollback() res.error = e.detail - except Exception as e: # noqa: BLE001 — surface any parse failure per-file, keep going + except storage.UploadTooLarge: session.rollback() - res.error = str(e) or "Could not process this file." + res.error = "File is too large." + except Exception: # noqa: BLE001 — keep going on any parse failure; don't leak internals + session.rollback() + res.error = "Could not process this file (unexpected format or error)." results.append(res) return BatchCapitalImportResult( diff --git a/backend/ten31portal/routers/import_router.py b/backend/ten31portal/routers/import_router.py index 9f6326a..ce1b267 100644 --- a/backend/ten31portal/routers/import_router.py +++ b/backend/ten31portal/routers/import_router.py @@ -16,6 +16,7 @@ import openpyxl from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile from sqlmodel import Session, select, col +from ten31portal import storage from ten31portal.audit import record_audit from ten31portal.auth import require_role from ten31portal.database import get_session @@ -342,7 +343,10 @@ def import_schedule( user: User = Depends(require_role(UserRole.approver, UserRole.cfo, UserRole.operations)), session: Session = Depends(get_session), ) -> dict[str, Any]: - file_bytes = file.file.read() + try: + file_bytes = storage.read_capped(file) + except storage.UploadTooLarge as exc: + raise HTTPException(status_code=413, detail=str(exc)) filename = file.filename or "" # Parse the file into holdings/positions previews. diff --git a/backend/ten31portal/storage.py b/backend/ten31portal/storage.py index 81a998e..0b3c134 100644 --- a/backend/ten31portal/storage.py +++ b/backend/ten31portal/storage.py @@ -14,6 +14,23 @@ class UploadTooLarge(Exception): """Raised when an upload exceeds MAX_UPLOAD_SIZE. The partial file is removed first.""" +def read_capped(file: UploadFile, limit: int = MAX_UPLOAD_SIZE) -> bytes: + """Read an upload fully into memory, but abort past `limit` instead of reading unbounded. + + The spreadsheet importers must parse the whole workbook in memory; without a cap an + authenticated writer could POST a huge file and exhaust the process. Reads in 1 MB chunks + and raises UploadTooLarge (→ 413) once the total would exceed the limit. + """ + chunks: list[bytes] = [] + size = 0 + while chunk := file.file.read(1024 * 1024): + size += len(chunk) + if size > limit: + raise UploadTooLarge(f"Upload exceeds the {limit}-byte limit.") + chunks.append(chunk) + return b"".join(chunks) + + def ensure_docs_dir() -> Path: path = Path(DOCS_DIR) path.mkdir(parents=True, exist_ok=True) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 3788fbe..22b1e2d 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -75,3 +75,13 @@ def auth_client(client, approver): ) assert resp.status_code == 200, resp.text return client + + +@pytest.fixture(autouse=True) +def _reset_login_limiter(): + """The login rate-limiter is a process-global; clear the TestClient's key around each test + so failed-login tests can't throttle unrelated ones.""" + from ten31portal.routers.auth_router import _login_limiter + _login_limiter.reset("testclient") + yield + _login_limiter.reset("testclient") diff --git a/backend/tests/test_auth_hardening.py b/backend/tests/test_auth_hardening.py new file mode 100644 index 0000000..2a3324a --- /dev/null +++ b/backend/tests/test_auth_hardening.py @@ -0,0 +1,59 @@ +"""Login hardening: generic failure message, timing-safe unknown-user path, rate limiting, +and the import size cap.""" + +import io + +import pytest + +from ten31portal import storage +from tests.conftest import make_user + + +def test_login_failure_is_generic_for_unknown_and_wrong_password(client, session): + make_user(session, username="alice", password="correct-horse") + + unknown = client.post("/api/auth/login", json={"login": "nobody", "password": "x"}) + wrong = client.post("/api/auth/login", json={"login": "alice", "password": "nope"}) + + assert unknown.status_code == 401 + assert wrong.status_code == 401 + # Same message either way, so it doesn't reveal whether the username exists. + assert unknown.json()["detail"] == wrong.json()["detail"] == "Invalid username or password" + + +def test_login_is_rate_limited_after_repeated_failures(client, session): + make_user(session, username="bob", password="s3cret-pass") + + for _ in range(10): + r = client.post("/api/auth/login", json={"login": "bob", "password": "wrong"}) + assert r.status_code == 401 + + blocked = client.post("/api/auth/login", json={"login": "bob", "password": "wrong"}) + assert blocked.status_code == 429 + assert "Retry-After" in blocked.headers + # Even the correct password is refused while the source IP is throttled. + correct = client.post("/api/auth/login", json={"login": "bob", "password": "s3cret-pass"}) + assert correct.status_code == 429 + + +def test_successful_login_clears_the_failure_counter(client, session): + make_user(session, username="carol", password="right-pass") + + for _ in range(9): # one shy of the limit + assert client.post("/api/auth/login", json={"login": "carol", "password": "no"}).status_code == 401 + assert client.post("/api/auth/login", json={"login": "carol", "password": "right-pass"}).status_code == 200 + + # Counter reset — a fresh run of failures doesn't immediately trip the limit. + assert client.post("/api/auth/login", json={"login": "carol", "password": "no"}).status_code == 401 + + +class _StubUpload: + """Minimal stand-in for UploadFile: read_capped only touches `.file.read`.""" + def __init__(self, data: bytes): + self.file = io.BytesIO(data) + + +def test_read_capped_enforces_the_limit(): + assert storage.read_capped(_StubUpload(b"x" * 100), limit=1000) == b"x" * 100 + with pytest.raises(storage.UploadTooLarge): + storage.read_capped(_StubUpload(b"x" * 2000), limit=1000) diff --git a/deploy/Dockerfile b/deploy/Dockerfile index f99ae05..bee02d7 100644 --- a/deploy/Dockerfile +++ b/deploy/Dockerfile @@ -34,6 +34,11 @@ RUN chmod +x ./start.sh # Data volume mount point RUN mkdir -p /data +# Unprivileged account for the server process. The container still starts as root (see start.sh) +# so it can chown the platform-mounted /data volume, then drops to this uid via setpriv. +RUN groupadd --gid 10001 appuser \ + && useradd --uid 10001 --gid 10001 --no-create-home --shell /usr/sbin/nologin appuser + ENV TEN31_DB_PATH=/data/portal.db ENV TEN31_DOCS_DIR=/data/documents ENV TEN31_SESSION_SECRET=change-me diff --git a/deploy/package.json b/deploy/package.json index 380b4a9..e30174e 100644 --- a/deploy/package.json +++ b/deploy/package.json @@ -1,6 +1,6 @@ { "name": "ten31portal-startos", - "version": "0.2.25", + "version": "0.2.26", "private": true, "scripts": { "build": "npm run check && rm -rf ./javascript && ncc build startos/index.ts -o ./javascript", diff --git a/deploy/start.sh b/deploy/start.sh index aa45918..f77cab2 100644 --- a/deploy/start.sh +++ b/deploy/start.sh @@ -14,31 +14,54 @@ fi # Create first approver on first boot if no users exist if [ ! -f /data/.initialized ]; then - echo "First boot: running migrations and creating default approver..." + echo "First boot: running migrations and creating the admin account..." - # Set default credentials (user can change via CLI later) ADMIN_NAME="${TEN31_ADMIN_NAME:-Jonathan}" ADMIN_USERNAME="${TEN31_ADMIN_USERNAME:-admin}" ADMIN_EMAIL="${TEN31_ADMIN_EMAIL:-jonathan@ten31.xyz}" - ADMIN_PASSWORD="${TEN31_ADMIN_PASSWORD:-Ten31}" - python3 -m ten31portal.cli create-user \ + # No weak default: use an operator-supplied password if given, else generate a strong random + # one and record it (0600) so it can be retrieved once via the "Show Initial Admin Password" + # action. There is no fixed default credential to guess. + ADMIN_PW_FILE="$(dirname "$TEN31_DB_PATH")/.admin-password" + if [ -n "$TEN31_ADMIN_PASSWORD" ]; then + ADMIN_PASSWORD="$TEN31_ADMIN_PASSWORD" + GENERATED="" + else + ADMIN_PASSWORD="$(python3 -c "import secrets; print(secrets.token_urlsafe(18))")" + GENERATED="yes" + fi + + if python3 -m ten31portal.cli create-user \ --name "$ADMIN_NAME" \ --username "$ADMIN_USERNAME" \ --email "$ADMIN_EMAIL" \ --role approver \ --password "$ADMIN_PASSWORD" \ - --service-admin || true + --service-admin; then + if [ -n "$GENERATED" ]; then + printf '%s' "$ADMIN_PASSWORD" > "$ADMIN_PW_FILE" + chmod 600 "$ADMIN_PW_FILE" + echo "Admin '$ADMIN_USERNAME' created with a generated password." + echo " Retrieve it once via the 'Show Initial Admin Password' service action, then change it." + else + echo "Admin '$ADMIN_USERNAME' created with the operator-supplied password." + fi + fi touch /data/.initialized - echo "Default approver created: username=$ADMIN_USERNAME" fi # Log level is overridable at runtime for debugging; defaults to info. LOG_LEVEL="${TEN31_LOG_LEVEL:-info}" -# Serve frontend static files from the backend -exec uvicorn ten31portal.main:app \ - --host 0.0.0.0 \ - --port 8000 \ - --log-level "$LOG_LEVEL" +# Hand off to the server as an unprivileged user. The platform mounts /data owned by root, so +# (while still root) we take ownership of the data volume first, then drop privileges with +# setpriv — the long-running server process is never root, limiting what a compromise can reach. +if [ "$(id -u)" = "0" ]; then + chown -R 10001:10001 /data + exec setpriv --reuid=10001 --regid=10001 --clear-groups \ + uvicorn ten31portal.main:app --host 0.0.0.0 --port 8000 --log-level "$LOG_LEVEL" +else + exec uvicorn ten31portal.main:app --host 0.0.0.0 --port 8000 --log-level "$LOG_LEVEL" +fi diff --git a/deploy/startos/actions/index.ts b/deploy/startos/actions/index.ts index 2d80130..b2cbb58 100644 --- a/deploy/startos/actions/index.ts +++ b/deploy/startos/actions/index.ts @@ -214,6 +214,44 @@ const listUsersAction = Action.withoutInput( }, ) +// ============================================ +// Action: Show Initial Admin Password +// ============================================ +const showAdminPasswordAction = Action.withoutInput( + 'show-admin-password', + { + name: 'Show Initial Admin Password', + description: + 'Reveal the randomly-generated admin password created on first boot. Sign in with it, then change your password — after which this no longer shows it.', + warning: null, + allowedStatuses: 'only-running', + group: null, + visibility: 'enabled', + }, + async ({ effects }) => { + try { + const result = await runCli(effects, ['show-admin-password'], 'show-admin-password-task') + if (result.exitCode !== 0) { + return errorResult(result.stderr?.toString() || 'Failed to read the admin password') + } + return { + version: '1' as const, + title: 'Initial Admin Password', + message: 'Sign in as "admin" with this password, then change it from the portal.', + result: { + type: 'single' as const, + value: (result.stdout?.toString() || '').trim() || 'No stored password.', + copyable: true, + qr: false, + masked: true, + }, + } + } catch (e: any) { + return errorResult(`Failed to read the admin password: ${e.message || e}`) + } + }, +) + // ============================================ // Action: Delete User // ============================================ @@ -379,6 +417,7 @@ const resetHoldingsAction = Action.withInput( export const actions = sdk.Actions.of() .addAction(createUserAction) .addAction(resetPasswordAction) + .addAction(showAdminPasswordAction) .addAction(listUsersAction) .addAction(deleteUserAction) .addAction(dedupeAction) diff --git a/deploy/startos/install/versions/index.ts b/deploy/startos/install/versions/index.ts index 2656f5a..ea7f38f 100644 --- a/deploy/startos/install/versions/index.ts +++ b/deploy/startos/install/versions/index.ts @@ -1,4 +1,4 @@ -export { v_0_2_25 as current } from './v_0_2_25' +export { v_0_2_26 as current } from './v_0_2_26' import { v_0_1_0 } from './v_0_1_0' import { v_0_2_0 } from './v_0_2_0' import { v_0_2_1 } from './v_0_2_1' @@ -24,4 +24,5 @@ import { v_0_2_21 } from './v_0_2_21' import { v_0_2_22 } from './v_0_2_22' import { v_0_2_23 } from './v_0_2_23' import { v_0_2_24 } from './v_0_2_24' -export const other = [v_0_1_0, v_0_2_0, v_0_2_1, v_0_2_3, v_0_2_4, v_0_2_5, v_0_2_6, v_0_2_7, v_0_2_8, v_0_2_9, v_0_2_10, v_0_2_11, v_0_2_12, v_0_2_13, v_0_2_14, v_0_2_15, v_0_2_16, v_0_2_17, v_0_2_18, v_0_2_19, v_0_2_20, v_0_2_21, v_0_2_22, v_0_2_23, v_0_2_24] +import { v_0_2_25 } from './v_0_2_25' +export const other = [v_0_1_0, v_0_2_0, v_0_2_1, v_0_2_3, v_0_2_4, v_0_2_5, v_0_2_6, v_0_2_7, v_0_2_8, v_0_2_9, v_0_2_10, v_0_2_11, v_0_2_12, v_0_2_13, v_0_2_14, v_0_2_15, v_0_2_16, v_0_2_17, v_0_2_18, v_0_2_19, v_0_2_20, v_0_2_21, v_0_2_22, v_0_2_23, v_0_2_24, v_0_2_25] diff --git a/deploy/startos/install/versions/v_0_2_26.ts b/deploy/startos/install/versions/v_0_2_26.ts new file mode 100644 index 0000000..5003e66 --- /dev/null +++ b/deploy/startos/install/versions/v_0_2_26.ts @@ -0,0 +1,13 @@ +import { VersionInfo } from '@start9labs/start-sdk' + +export const v_0_2_26 = VersionInfo.of({ + version: '0.2.26:0', + releaseNotes: { + en_US: + 'Security hardening (from a full audit): no more fixed default admin password — first boot generates a strong random one, retrievable once via the new "Show Initial Admin Password" action and cleared after you change it. Login now rate-limits repeated failures and returns a single generic message (no username enumeration). The server process runs unprivileged (non-root). Spreadsheet imports are size-capped and batch import errors no longer leak internal details.', + }, + migrations: { + up: async ({ effects }) => {}, + down: async ({ effects }) => {}, + }, +}) diff --git a/frontend/public/sw.js b/frontend/public/sw.js index 40b9e62..81e9bc0 100644 --- a/frontend/public/sw.js +++ b/frontend/public/sw.js @@ -3,7 +3,7 @@ // - content-hashed /assets/* are cache-first (immutable, safe forever) // - /api/* is never cached // Bump CACHE on each release so old entries are purged. -const CACHE = 'ten31-portal-0.2.25' +const CACHE = 'ten31-portal-0.2.26' self.addEventListener('install', () => self.skipWaiting()) diff --git a/frontend/src/version.ts b/frontend/src/version.ts index 302fb97..85e3d1a 100644 --- a/frontend/src/version.ts +++ b/frontend/src/version.ts @@ -1,4 +1,4 @@ // Bumped each release so the running build is visible in the UI. // If the number shown in the app doesn't match the installed s9pk version, // the new frontend isn't actually being served. -export const APP_VERSION = "0.2.25"; +export const APP_VERSION = "0.2.26";