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
+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)