0.2.26: security hardening from audit (P2/P3 fixes)

Address the security-auditor findings the user selected (items 2, 3, 4):

Default admin credentials (P2): remove the fixed `Ten31` default. First
boot now generates a strong random admin password (secrets.token_urlsafe),
records it 0600 at /data/.admin-password, and surfaces it once via a new
"Show Initial Admin Password" StartOS action (CLI `show-admin-password`).
The stored password is cleared when the admin is reset (CLI reset-password)
or self-changes it (change-password endpoint).

Login hardening (P2): add a per-IP in-memory sliding-window rate limiter
(10 failures / 5 min -> 429 + Retry-After) in ratelimit.py; run a dummy
argon2 verify when the user is unknown so timing can't enumerate usernames;
keep a single generic 401 for unknown-user and wrong-password.

Hardening (P3): server process now runs unprivileged -- Dockerfile adds
uid 10001 appuser; start.sh (still root) chowns the mounted /data then
drops via `setpriv` before exec'ing uvicorn. Spreadsheet imports are
size-capped via storage.read_capped (413 past MAX_UPLOAD_SIZE) in the
schedule, capital preview, and batch paths. batch_import no longer returns
raw exception text (generic per-file messages).

Verified in the packed amd64 container: PID1 uvicorn runs as uid 10001,
/data owned 10001 with 0600 secrets; generated admin password retrievable
via CLI and logs in (200); 11th bad login -> 429; admin reset clears the
stored password. Tests: test_auth_hardening.py (4). Full suite 21 passed;
frontend tsc + StartOS bundle clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Jonathan Kirkwood
2026-07-01 19:09:27 -05:00
co-authored by Claude Opus 4.8
parent 025aff4fac
commit 69f12b0519
17 changed files with 324 additions and 23 deletions
+32
View File
@@ -1,10 +1,12 @@
"""CLI commands for Ten31Portal (also driven by the StartOS service Actions).""" """CLI commands for Ten31Portal (also driven by the StartOS service Actions)."""
import argparse import argparse
import os
import sys import sys
from sqlmodel import Session, select from sqlmodel import Session, select
from ten31portal import config
from ten31portal.auth import hash_password from ten31portal.auth import hash_password
from ten31portal.database import engine from ten31portal.database import engine
from ten31portal.db_init import run_migrations 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}") 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: def reset_password(args: argparse.Namespace) -> None:
"""Reset a user's password (and re-enable their login).""" """Reset a user's password (and re-enable their login)."""
run_migrations() run_migrations()
@@ -68,11 +78,29 @@ def reset_password(args: argparse.Namespace) -> None:
sys.exit(1) sys.exit(1)
user.password_hash = hash_password(args.password) user.password_hash = hash_password(args.password)
user.login_enabled = True user.login_enabled = True
is_admin = user.is_service_admin
session.add(user) session.add(user)
session.commit() 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}).") 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: def list_users(args: argparse.Namespace) -> None:
"""Print all user accounts.""" """Print all user accounts."""
run_migrations() run_migrations()
@@ -240,6 +268,8 @@ def main() -> None:
sub.add_parser("list-users", help="List all user accounts") 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 = sub.add_parser("delete-user", help="Delete a user (not the Service Admin)")
delete.add_argument("--username", required=False, default=None) delete.add_argument("--username", required=False, default=None)
delete.add_argument("--email", required=False, default=None) delete.add_argument("--email", required=False, default=None)
@@ -261,6 +291,8 @@ def main() -> None:
reset_password(args) reset_password(args)
elif args.command == "list-users": elif args.command == "list-users":
list_users(args) list_users(args)
elif args.command == "show-admin-password":
show_admin_password(args)
elif args.command == "delete-user": elif args.command == "delete-user":
delete_user(args) delete_user(args)
elif args.command == "dedupe-holdings": elif args.command == "dedupe-holdings":
+7 -1
View File
@@ -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") SESSION_SECRET: str = os.getenv("TEN31_SESSION_SECRET", "change-me-in-production")
DOCS_DIR: str = os.getenv("TEN31_DOCS_DIR", "/data/ten31portal/documents") 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 # 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))) 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")
+47
View File
@@ -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)
+37 -1
View File
@@ -1,15 +1,27 @@
"""Authentication endpoints.""" """Authentication endpoints."""
import os
import secrets
from fastapi import APIRouter, Depends, HTTPException, Request from fastapi import APIRouter, Depends, HTTPException, Request
from sqlmodel import Session, select from sqlmodel import Session, select
from ten31portal import config
from ten31portal.auth import get_current_user, hash_password, verify_password from ten31portal.auth import get_current_user, hash_password, verify_password
from ten31portal.database import get_session from ten31portal.database import get_session
from ten31portal.models import User from ten31portal.models import User
from ten31portal.ratelimit import SlidingWindowLimiter
from ten31portal.schemas import ChangePasswordRequest, LoginRequest, UserResponse from ten31portal.schemas import ChangePasswordRequest, LoginRequest, UserResponse
router = APIRouter(prefix="/api/auth", tags=["auth"]) 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") @router.post("/login")
def login( def login(
@@ -17,13 +29,30 @@ def login(
request: Request, request: Request,
session: Session = Depends(get_session), session: Session = Depends(get_session),
) -> UserResponse: ) -> 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. # Accept either a username or an email in the login field.
handle = body.login.strip() handle = body.login.strip()
user = session.exec(select(User).where(User.username == handle)).first() user = session.exec(select(User).where(User.username == handle)).first()
if user is None: if user is None:
user = session.exec(select(User).where(User.email == handle)).first() 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") 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: if user.primary_account_id is not None:
raise HTTPException( raise HTTPException(
status_code=401, status_code=401,
@@ -33,6 +62,7 @@ def login(
raise HTTPException(status_code=401, detail="This account does not have a login yet.") raise HTTPException(status_code=401, detail="This account does not have a login yet.")
if not user.is_active: if not user.is_active:
raise HTTPException(status_code=401, detail="Account disabled") raise HTTPException(status_code=401, detail="Account disabled")
_login_limiter.reset(client_ip)
request.session["user_id"] = user.id request.session["user_id"] = user.id
return UserResponse.model_validate(user, from_attributes=True) return UserResponse.model_validate(user, from_attributes=True)
@@ -62,4 +92,10 @@ def change_password(
user.password_hash = hash_password(body.new_password) user.password_hash = hash_password(body.new_password)
session.add(user) session.add(user)
session.commit() 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"} return {"status": "ok"}
@@ -19,6 +19,7 @@ import openpyxl
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
from sqlmodel import Session, select from sqlmodel import Session, select
from ten31portal import storage
from ten31portal.audit import record_audit from ten31portal.audit import record_audit
from ten31portal.auth import hash_password, require_internal_admin from ten31portal.auth import hash_password, require_internal_admin
from ten31portal.database import get_session 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: if entity_id is not None and session.get(Entity, entity_id) is None:
raise HTTPException(status_code=404, detail="Entity not found") 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() investors = session.exec(select(User).where(User.role == UserRole.investor)).all()
by_name = {u.name.strip().lower(): u for u in investors} by_name = {u.name.strip().lower(): u for u in investors}
@@ -388,7 +393,7 @@ def batch_import(
fname = upload.filename or "(unnamed)" fname = upload.filename or "(unnamed)"
res = BatchCapitalFileResult(filename=fname) res = BatchCapitalFileResult(filename=fname)
try: try:
wb = _open_workbook(upload.file.read(), password) wb = _open_workbook(storage.read_capped(upload), password)
if "ALLOC SI" not in wb.sheetnames: if "ALLOC SI" not in wb.sheetnames:
raise HTTPException(status_code=422, detail="No ALLOC SI tab found in this workbook.") raise HTTPException(status_code=422, detail="No ALLOC SI tab found in this workbook.")
as_of, roster = _parse_alloc_si(wb) as_of, roster = _parse_alloc_si(wb)
@@ -452,11 +457,15 @@ def batch_import(
session.commit() session.commit()
total_statements += res.statements_written total_statements += res.statements_written
except HTTPException as e: except HTTPException as e:
# Our own controlled messages (bad password, no ALLOC SI, no date) are safe to show.
session.rollback() session.rollback()
res.error = e.detail res.error = e.detail
except Exception as e: # noqa: BLE001 — surface any parse failure per-file, keep going except storage.UploadTooLarge:
session.rollback() 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) results.append(res)
return BatchCapitalImportResult( return BatchCapitalImportResult(
+5 -1
View File
@@ -16,6 +16,7 @@ import openpyxl
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile
from sqlmodel import Session, select, col from sqlmodel import Session, select, col
from ten31portal import storage
from ten31portal.audit import record_audit from ten31portal.audit import record_audit
from ten31portal.auth import require_role from ten31portal.auth import require_role
from ten31portal.database import get_session from ten31portal.database import get_session
@@ -342,7 +343,10 @@ def import_schedule(
user: User = Depends(require_role(UserRole.approver, UserRole.cfo, UserRole.operations)), user: User = Depends(require_role(UserRole.approver, UserRole.cfo, UserRole.operations)),
session: Session = Depends(get_session), session: Session = Depends(get_session),
) -> dict[str, Any]: ) -> 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 "" filename = file.filename or ""
# Parse the file into holdings/positions previews. # Parse the file into holdings/positions previews.
+17
View File
@@ -14,6 +14,23 @@ class UploadTooLarge(Exception):
"""Raised when an upload exceeds MAX_UPLOAD_SIZE. The partial file is removed first.""" """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: def ensure_docs_dir() -> Path:
path = Path(DOCS_DIR) path = Path(DOCS_DIR)
path.mkdir(parents=True, exist_ok=True) path.mkdir(parents=True, exist_ok=True)
+10
View File
@@ -75,3 +75,13 @@ def auth_client(client, approver):
) )
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
return client 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")
+59
View File
@@ -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)
+5
View File
@@ -34,6 +34,11 @@ RUN chmod +x ./start.sh
# Data volume mount point # Data volume mount point
RUN mkdir -p /data 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_DB_PATH=/data/portal.db
ENV TEN31_DOCS_DIR=/data/documents ENV TEN31_DOCS_DIR=/data/documents
ENV TEN31_SESSION_SECRET=change-me ENV TEN31_SESSION_SECRET=change-me
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "ten31portal-startos", "name": "ten31portal-startos",
"version": "0.2.25", "version": "0.2.26",
"private": true, "private": true,
"scripts": { "scripts": {
"build": "npm run check && rm -rf ./javascript && ncc build startos/index.ts -o ./javascript", "build": "npm run check && rm -rf ./javascript && ncc build startos/index.ts -o ./javascript",
+34 -11
View File
@@ -14,31 +14,54 @@ fi
# Create first approver on first boot if no users exist # Create first approver on first boot if no users exist
if [ ! -f /data/.initialized ]; then 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_NAME="${TEN31_ADMIN_NAME:-Jonathan}"
ADMIN_USERNAME="${TEN31_ADMIN_USERNAME:-admin}" ADMIN_USERNAME="${TEN31_ADMIN_USERNAME:-admin}"
ADMIN_EMAIL="${TEN31_ADMIN_EMAIL:-jonathan@ten31.xyz}" 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" \ --name "$ADMIN_NAME" \
--username "$ADMIN_USERNAME" \ --username "$ADMIN_USERNAME" \
--email "$ADMIN_EMAIL" \ --email "$ADMIN_EMAIL" \
--role approver \ --role approver \
--password "$ADMIN_PASSWORD" \ --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 touch /data/.initialized
echo "Default approver created: username=$ADMIN_USERNAME"
fi fi
# Log level is overridable at runtime for debugging; defaults to info. # Log level is overridable at runtime for debugging; defaults to info.
LOG_LEVEL="${TEN31_LOG_LEVEL:-info}" LOG_LEVEL="${TEN31_LOG_LEVEL:-info}"
# Serve frontend static files from the backend # Hand off to the server as an unprivileged user. The platform mounts /data owned by root, so
exec uvicorn ten31portal.main:app \ # (while still root) we take ownership of the data volume first, then drop privileges with
--host 0.0.0.0 \ # setpriv — the long-running server process is never root, limiting what a compromise can reach.
--port 8000 \ if [ "$(id -u)" = "0" ]; then
--log-level "$LOG_LEVEL" 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
+39
View File
@@ -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 // Action: Delete User
// ============================================ // ============================================
@@ -379,6 +417,7 @@ const resetHoldingsAction = Action.withInput(
export const actions = sdk.Actions.of() export const actions = sdk.Actions.of()
.addAction(createUserAction) .addAction(createUserAction)
.addAction(resetPasswordAction) .addAction(resetPasswordAction)
.addAction(showAdminPasswordAction)
.addAction(listUsersAction) .addAction(listUsersAction)
.addAction(deleteUserAction) .addAction(deleteUserAction)
.addAction(dedupeAction) .addAction(dedupeAction)
+3 -2
View File
@@ -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_1_0 } from './v_0_1_0'
import { v_0_2_0 } from './v_0_2_0' import { v_0_2_0 } from './v_0_2_0'
import { v_0_2_1 } from './v_0_2_1' 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_22 } from './v_0_2_22'
import { v_0_2_23 } from './v_0_2_23' import { v_0_2_23 } from './v_0_2_23'
import { v_0_2_24 } from './v_0_2_24' 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]
@@ -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 }) => {},
},
})
+1 -1
View File
@@ -3,7 +3,7 @@
// - content-hashed /assets/* are cache-first (immutable, safe forever) // - content-hashed /assets/* are cache-first (immutable, safe forever)
// - /api/* is never cached // - /api/* is never cached
// Bump CACHE on each release so old entries are purged. // 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()) self.addEventListener('install', () => self.skipWaiting())
+1 -1
View File
@@ -1,4 +1,4 @@
// Bumped each release so the running build is visible in the UI. // 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, // If the number shown in the app doesn't match the installed s9pk version,
// the new frontend isn't actually being served. // the new frontend isn't actually being served.
export const APP_VERSION = "0.2.25"; export const APP_VERSION = "0.2.26";