0.2.26: security hardening from audit (P2/P3 fixes)
Address the security-auditor findings the user selected (items 2, 3, 4): Default admin credentials (P2): remove the fixed `Ten31` default. First boot now generates a strong random admin password (secrets.token_urlsafe), records it 0600 at /data/.admin-password, and surfaces it once via a new "Show Initial Admin Password" StartOS action (CLI `show-admin-password`). The stored password is cleared when the admin is reset (CLI reset-password) or self-changes it (change-password endpoint). Login hardening (P2): add a per-IP in-memory sliding-window rate limiter (10 failures / 5 min -> 429 + Retry-After) in ratelimit.py; run a dummy argon2 verify when the user is unknown so timing can't enumerate usernames; keep a single generic 401 for unknown-user and wrong-password. Hardening (P3): server process now runs unprivileged -- Dockerfile adds uid 10001 appuser; start.sh (still root) chowns the mounted /data then drops via `setpriv` before exec'ing uvicorn. Spreadsheet imports are size-capped via storage.read_capped (413 past MAX_UPLOAD_SIZE) in the schedule, capital preview, and batch paths. batch_import no longer returns raw exception text (generic per-file messages). Verified in the packed amd64 container: PID1 uvicorn runs as uid 10001, /data owned 10001 with 0600 secrets; generated admin password retrievable via CLI and logs in (200); 11th bad login -> 429; admin reset clears the stored password. Tests: test_auth_hardening.py (4). Full suite 21 passed; frontend tsc + StartOS bundle clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
025aff4fac
commit
69f12b0519
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user