diff --git a/SECURITY-AUDIT.md b/SECURITY-AUDIT.md new file mode 100644 index 0000000..4be410a --- /dev/null +++ b/SECURITY-AUDIT.md @@ -0,0 +1,283 @@ +# Ten31Portal pre-release security audit + +## Remediation status (2026-08-28) + +Fixed and covered by tests in `backend/tests/test_security_hardening.py` (76 tests pass): + +- **#1 Cross-tenant investor takeover (Critical)** — fixed. A shared `check_target_investor_scope` + now gates every write that could mint an access grant or a statement for an arbitrary investor: + `grant_access`, `create_statement`, `set_partner_exited`, and the import "match" action. An + external Administrator can only ever act on an investor already inside their funds. +- **#2 Internal role/is_active mass-assignment (Critical)** — fixed. `_enforce_privileged_fields` + blocks self-promotion, minting or tampering with approver accounts by non-approvers, and any + change to the Service Admin's role or status. +- **#3 Shared default password bypass (Critical)** — fixed. `must_change_password` is now enforced + server-side: such an account may reach only `me`, `change-password`, and `logout` until it + rotates. (The shared default is now safe; moving to per-investor one-time secrets is still worth + doing and is left as a follow-up.) +- **#4 Global login-throttle DoS (High)** — fixed. `start.sh` runs uvicorn with `--proxy-headers` + so the limiter keys on the real client IP behind the platform proxy. Depends on the StartOS + proxy forwarding `X-Forwarded-For`; verify on the box. +- **#5 Session-secret fallback (High)** — fixed. The app refuses to boot on a known/short secret; + the `start.sh` placeholder list now matches `config.py`. +- **#6 Cookie hardening (Medium)** — partly fixed. `Secure` (via `TEN31_SESSION_COOKIE_SECURE`, + on in the image), `SameSite=lax`, and a 12-hour max-age are set. Server-side session revocation + is still a follow-up (sessions remain stateless signed cookies). +- **#7 Security headers (Medium)** — fixed. CSP, `X-Frame-Options: DENY`, `nosniff`, + `Referrer-Policy`, and HSTS are set on every response; CSP verified against the built SPA with no + violations. +- **#9 Import-preview cross-fund leak (Medium)** — fixed. External Administrators must name a fund + and only see matches against investors in their scope. +- **P3 quick wins** — fixed: `Cache-Control: no-store` on document downloads; `nosniff` (via #7). + +Still outstanding (documented below, not yet coded): **#8** XLSX zip-bomb / dimension memory +exhaustion and unthrottled argon2 on change-password / TOTP-disable; the money-as-float / Decimal +and range-validation cleanup; TOTP secret-at-rest encryption and replay protection; list-endpoint +pagination. These are Medium/Low and were left as a reviewed backlog rather than rushed in. + +--- + +# Original findings + +Date: 2026-08-28. Method: 10-agent adversarial swarm (one finder plus one independent +verifier per attack surface), followed by hand re-verification of every Critical and High +finding against the source. Overlapping findings are deduped below. Line numbers are from the +audited tree. + +Scope reviewed: backend routers, auth/session/2FA, file storage and document serving, +CSV/XLSX import, money handling, rate limiting, StartOS deploy, and the React frontend. + +## The short version + +The app is written defensively (argon2, timing-safe login, path-traversal guards, upload +caps, privilege-dropping container). The real exposure is in **authorization logic**, not +memory-safety or injection. Three issues would let one account reach another account's money +and documents, or let one staff member seize the whole system. Fix the three P0 items before +any investor logs in. The P1/P2 items should follow before or immediately after launch. + +| # | Severity | Issue | Core location | +|---|----------|-------|---------------| +| 1 | Critical | A fund administrator can take over any investor account in any fund | `user_router.py:144,157,522` | +| 2 | Critical | operations/cfo can self-promote to approver and disable other admins | `user_router.py:405-457,362-402`; `schemas.py:79-80` | +| 3 | Critical | Shared default investor password, forced-change enforced only in the browser | `config.py:17`; `capital_import_router.py:324-335`; `auth_router.py:85-98` | +| 4 | High | Login throttle is effectively global behind the proxy: 10 bad tries locks out everyone | `auth_router.py:33,49`; `ratelimit.py` | +| 5 | High | Session signing key silently falls back to a published constant | `config.py:7`; `start.sh:5`; `Dockerfile:44` | +| 6 | Medium | Signed-cookie sessions cannot be revoked; no Secure flag; up to 14-day life | `main.py:37`; `auth_router.py:144` | +| 7 | Medium | No security headers (CSP, X-Frame-Options, nosniff, Referrer-Policy, HSTS) | `main.py:36-52` | +| 8 | Medium | Malicious XLSX (zip bomb / huge declared dimension) exhausts the single worker | `import_router.py:72-83`; `capital_import_router.py:230-239` | +| 9 | Medium | Import preview leaks investor usernames across funds when entity_id is omitted | `capital_import_router.py:221-267` | +| 10 | Low set | Money-as-float, doc caching, latent stored XSS, self-approval, dev-server entrypoint, etc. | see below | + +--- + +## P0 - fix before release + +### 1. Cross-tenant investor account takeover by a fund administrator (Critical) + +`fund_administrator` is an external, entity-scoped, semi-trusted third party. It should never +reach an investor outside its own funds, and never seize an investor login. It can do both. + +The scope model has a gap: several write endpoints check that the *entity* is in the caller's +grants, but not that the *target investor* is already in scope. Because "in scope" is derived +from `EntityAccess` and `CapitalAccountStatement` rows (`_visible_investor_ids`, +`user_router.py:27-40`), any endpoint that *creates* one of those rows for an arbitrary +investor pulls that investor into the attacker's scope. After that, the stricter checks pass. + +Exploit chain: + +1. Attacker is any `fund_administrator`, holding a grant on their own entity `E`. +2. `PUT /api/users/{victim_id}/access/{E}` (`grant_access`, `user_router.py:137`). It calls + only `check_administrator_scope(admin, E)` (entity check) and requires the target be an + investor. It creates `EntityAccess(victim_id, E)` for any investor id, enumerable. +3. The victim is now returned by `_visible_investor_ids`, so they are "in scope." +4. `POST /api/users/{victim_id}/reset-password` (`reset_password`, `user_router.py:512`). Its + `_check_target_in_scope` now passes. It sets `password_hash` to an attacker-chosen value, + `login_enabled=True`, `must_change_password=False` (`user_router.py:523-526`). +5. Attacker logs in as the victim. Unless the victim self-enrolled TOTP, this is full takeover: + their capital account, statements, and private documents (K-1s). + +The same "mint a scope-granting row for an arbitrary investor" gap exists at three more +injection points, so fixing only `grant_access` is not enough: + +- `create_statement` (`capital_account_router.py:126-173`): writes a `CapitalAccountStatement` + for any investor id on the caller's entity, both fabricating capital figures under the + victim's name and bringing them into scope. +- `set_partner_exited` (`entity_router.py:162-197`): creates an `EntityAccess` row for an + arbitrary investor when marking an exit. +- `commit_import` "match" (`capital_import_router.py:299,339-382`): binds an arbitrary existing + investor to the caller's fund. + +Fix: in every one of these endpoints, when `accessible_entity_ids(admin)` is not None, require +the target investor to already be within scope before writing (call `_check_target_in_scope`, +or verify membership in `_visible_investor_ids`). The clean invariant: an external +Administrator can only ever act on an investor whose footprint is already a subset of their +grants, exactly the check `delete_user` already performs at `user_router.py:554-567`. Consider +also decoupling "reset password" from "is currently visible" so a freshly minted grant cannot +immediately unlock a credential reset. + +### 2. Internal privilege escalation via mass-assignment (Critical) + +`update_user` (`PATCH /api/users/{id}`, `user_router.py:405`) and `create_user` +(`POST /api/users`, `user_router.py:362`) are gated by `require_admin`, which includes +`operations` and `cfo`. `UserUpdate` exposes `role` and `is_active` as client-settable fields +(`schemas.py:79-80`); `UserCreate` exposes `role` (`schemas.py:70`). + +The only role guard sits inside `if scope is not None` (`user_router.py:421-424`). For every +internal role `accessible_entity_ids` returns None (`auth.py:71`), so that block is skipped and +execution falls through to `setattr(user, key, val)` (`user_router.py:445-446`), writing `role` +and `is_active` verbatim. There is no self-edit guard and no `is_service_admin` guard on this +path (unlike `delete_user`). + +Consequences for any authenticated `operations` or `cfo` account: + +- `PATCH /api/users/{own_id}` with `{"role":"approver"}` grants final valuation sign-off, the + one authority `operations` is explicitly denied (`models.py:16-17`, `round_router.py:182`). + Segregation of duties collapses. +- `POST /api/users` with `{"role":"approver", ...}` mints brand-new approver accounts. +- `PATCH` with `{"is_active":false}` on any approver or the Service Admin locks them out. + +Fix: strip `role` and `is_active` from the client-settable set for everyone except an explicit +role-management path guarded by `require_approver`; forbid self-role-change and any change to +`is_service_admin` accounts; treat `UserUpdate`/`UserCreate` role changes as a privileged +operation, not a field write. + +### 3. Shared default investor password, forced change enforced only in the browser (Critical) + +Imported and CLI-created investor accounts are minted with `login_enabled=True` and +`password_hash = hash(DEFAULT_INVESTOR_PASSWORD)` where `DEFAULT_INVESTOR_PASSWORD = "Ten31Portal"` +(`config.py:17`, `capital_import_router.py:324-335`). `must_change_password` is set True, but +nothing on the server enforces it: `login` issues a full authenticated session regardless +(`auth_router.py:85-98`), and no dependency in `auth.py` blocks API calls when the flag is set. +The forced-change screen exists only in the React client (`frontend/src/App.tsx`). + +So during the window between account creation and the investor's first manual password change, +anyone who knows or guesses an investor's username or email (investor emails are frequently +known) can log in with the constant `"Ten31Portal"` and, by calling the API directly, skip the +change-password screen entirely and read that investor's capital account and documents. + +Fix: enforce `must_change_password` server-side. A user with the flag set should get a session +that is allowed to hit only `change-password`, `me`, and `logout`, and be 403'd everywhere else +until they rotate. Better still, do not set a shared password at all: generate a unique random +initial secret per investor (as `start.sh` already does for the admin) and surface it once to +the operator, or drive onboarding through a one-time invite token instead of a standing default. + +--- + +## P1 - fix before or right at launch + +### 4. Login throttle is effectively global behind the proxy (High, availability) + +The limiter is keyed on `request.client.host` (`auth_router.py:49,108`). StartOS serves the +service behind its own reverse proxy and the app does not parse forwarded headers, so +`request.client.host` is the proxy's single address for every user. The limiter +(`SlidingWindowLimiter(max_attempts=10, window=300)`) therefore behaves as one global bucket. + +Impact: any unauthenticated visitor can send 10 bad logins and lock out *every* user for five +minutes, repeatable indefinitely, a trivial login denial-of-service against the whole firm. The +same coupling means real per-source throttling is impossible. + +Fix: terminate identity at the proxy and pass a trusted client IP (uvicorn `--proxy-headers` +with a locked-down trusted-hosts set), then key the limiter on that. Add a separate per-account +failure counter with backoff (not a hard lockout by name) so one noisy source cannot deny +everyone, and apply the same throttle to `login/verify-totp` per account. + +### 5. Session signing key can silently fall back to a published constant (High) + +`config.SESSION_SECRET` defaults to the literal `"change-me-in-production"` (`config.py:7`). +`start.sh` only replaces it when the value is exactly `"change-me"` (`start.sh:5`), the sentinel +the Dockerfile sets (`Dockerfile:44`). The two sentinels do not match. If the service is ever +run with `TEN31_SESSION_SECRET` unset, empty, or set to anything other than `"change-me"` +(a different entrypoint, a compose override, a future packaging change), the guard does not +fire and the app boots on the hardcoded, source-visible key. Anyone can then forge a valid +session cookie for any user id and bypass authentication entirely. + +Fix: make the app refuse to start when `SESSION_SECRET` is the known default or shorter than a +safe length, rather than silently accepting it. Align the `start.sh` sentinel with the config +default (or generate unconditionally when it matches either sentinel). + +--- + +## P2 - fix soon after launch + +### 6. Signed-cookie sessions cannot be revoked; cookie hardening (Medium) + +`SessionMiddleware` stores the session in a client-side signed cookie (`main.py:37`), so the +SPEC's "server-side sessions" claim does not hold. There is no server-side session store and no +revocation: `logout` (`auth_router.py:144`) only clears the client's copy, and password change +or TOTP disable do not invalidate an already-stolen cookie. With the Starlette default max-age +(14 days), a captured cookie stays valid for up to two weeks. The cookie is also added without +`https_only=True`, so it has no `Secure` flag and can leak over any plaintext hop. Deactivating +a user is honored on each request (`auth.py:33`), which is the one saving grace. + +Fix: pass `https_only=True` and a shorter `max_age` to `SessionMiddleware`. For real +revocation, add a server-side session table or a per-user `session_epoch` that increments on +logout, password change, and TOTP disable, and reject cookies minted before it. + +### 7. Missing HTTP security headers (Medium) + +No CSP, `X-Frame-Options`/frame-ancestors (clickjacking), `X-Content-Type-Options: nosniff`, +`Referrer-Policy`, or HSTS are set anywhere (`main.py:36-52`, SPA served via bare +`FileResponse`). Fix: add a small middleware that sets these on every response; nosniff also +hardens the document-download path against content sniffing (see item 10). + +### 8. XLSX decompression / dimension memory exhaustion (Medium, availability) + +The 50 MB upload cap (`storage.py`) bounds bytes on the wire, but a small crafted `.xlsx` +(zip bomb, or a tiny sheet declaring an enormous used range) can expand to gigabytes when +parsed (`import_router.py:72-83,145`; `capital_import_router.py:230-239`). On the single-worker +deployment this takes the portal down. Related: `change-password` and `totp-disable` run an +unthrottled argon2 verify (`auth_router.py`), a cheap way to pin CPU. Fix: parse with +read-only/streaming mode and cap row/column counts and decompressed size; throttle the argon2 +endpoints. + +### 9. Import-preview cross-fund username disclosure (Medium) + +The capital-import preview path skips the administrator scope check when `entity_id` is omitted +(`capital_import_router.py:221-267`), letting an external Administrator confirm whether given +investor usernames exist in other funds. Related lower-severity leak: `get_user` returns an +investor's full cross-fund entity ids and linked-account names to an in-scope external +Administrator (`user_router.py:285-308`). Fix: require and enforce entity scope on preview; +filter `_entity_ids_for` and `linked_accounts` to the caller's scope. + +--- + +## P3 - hardening backlog (Low / defense-in-depth) + +- **Document downloads lack `Cache-Control: no-store`** (`document_router.py:153`): K-1s and + statements can linger in shared-machine browser caches. +- **Uploaded Content-Type is stored unvalidated and echoed on download** + (`document_router.py:120,155`; `storage.py:47`): currently defused because `FileResponse` + forces `Content-Disposition: attachment`. Keep the attachment disposition, add `nosniff`, and + consider allow-listing upload types so this stays latent. +- **Money handled as float**: `round(dollars*100)` instead of integer/Decimal across + `capital_account_router.py:22-23`, `position_router.py:19-21`, `import_router.py:50,65-67`. + No sign/range validation on `value_cents`/cents inputs (`schemas.py:202,255-263`), `abs()` + forces distributions positive and flips legitimate negative reversals + (`capital_import_router.py:158`), and NaN/Infinity floats can reach `round()` and persist. + Validate sign/range and parse money with `Decimal`. +- **No maker-checker on valuation sign-off** (`round_router.py:179-202`): the same user can + submit and approve. Enforce a different approver. +- **Packaged `ten31portal` console entrypoint runs `uvicorn --reload` on 0.0.0.0** + (`main.py:103-105`, `pyproject.toml:28-29`). Confirm production uses `start.sh` (it does) and + drop or guard the reload entrypoint so it cannot be the prod launch by accident. +- **TOTP**: secret stored in plaintext (`models.py:86`); codes replayable within the validation + window (no last-used timestep, `totp.py:37-39`); brute-force throttle is global-per-IP only, + no per-account cap. Encrypt the secret at rest, record and reject the last-used step, add a + per-account 2FA counter. +- **Upload size enforced only after Starlette buffers the whole multipart body** + (`storage.py:40-63`): add a global request-size limit at the proxy. +- **Unbounded, un-paginated list endpoints with N+1 queries** + (`document_router.py:48-80`, `entity_router.py:39-112`): paginate and batch. + +--- + +## Method note and honest caveats + +The swarm's automated verifier confirmed all 38 raw findings and refuted none, which is weaker +skepticism than ideal. Because of that, every Critical and High item above was re-checked by +hand against the source before being written here, and the raw findings were deduped (the +default-password issue, cookie flags, security headers, session-secret fallback, and +money-as-float each surfaced from multiple angles and are listed once). The P3 items are lower +confidence and lower impact; treat them as a backlog, not blockers. This review is static +analysis of the code; it is not a penetration test against a running instance, and it does not +cover the StartOS platform's own network exposure or the reverse proxy's TLS configuration. diff --git a/backend/ten31portal/auth.py b/backend/ten31portal/auth.py index 9d68532..67d2b8f 100644 --- a/backend/ten31portal/auth.py +++ b/backend/ten31portal/auth.py @@ -8,7 +8,9 @@ from fastapi import Depends, HTTPException, Request from sqlmodel import Session, select, col from ten31portal.database import get_session -from ten31portal.models import EntityAccess, User, UserRole, EXTERNAL_ROLES +from ten31portal.models import ( + CapitalAccountStatement, EntityAccess, User, UserRole, EXTERNAL_ROLES, +) ph = PasswordHasher() @@ -24,6 +26,16 @@ def verify_password(password: str, password_hash: str) -> bool: return False +# While an account is on the shared default password it may reach only these endpoints, so the +# forced password change cannot be skipped by calling the API directly (the browser modal is not +# a security control). Everything else is blocked until the password is rotated. +_PASSWORD_CHANGE_ALLOWED = frozenset({ + "/api/auth/me", + "/api/auth/change-password", + "/api/auth/logout", +}) + + def get_current_user(request: Request, session: Session = Depends(get_session)) -> User: """FastAPI dependency: extract user from session cookie.""" user_id = request.session.get("user_id") @@ -32,6 +44,8 @@ def get_current_user(request: Request, session: Session = Depends(get_session)) user = session.get(User, user_id) if user is None or not user.is_active: raise HTTPException(status_code=401, detail="Not authenticated") + if user.must_change_password and request.url.path not in _PASSWORD_CHANGE_ALLOWED: + raise HTTPException(status_code=403, detail="password_change_required") return user @@ -124,3 +138,38 @@ def check_administrator_scope(user: User, entity_id: int, session: Session) -> N """ if user.role in EXTERNAL_ROLES and not can_access_entity(user, entity_id, session): raise HTTPException(status_code=403, detail="No access to this entity") + + +def visible_investor_ids(user: User, session: Session) -> set[int] | None: + """Investor ids an external account may see or manage. None means unrestricted (internal). + + An investor is in scope when they already have an access grant or a capital-account + statement in one of the caller's entities. This is the set an external Administrator is + allowed to touch; it is derived from rows that predate the current request, so a write + that would CREATE such a row for a new investor must be scope-checked BEFORE it runs. + """ + scope = accessible_entity_ids(user, session) + if scope is None: + return None + if not scope: + return set() + ids = set(session.exec( + select(EntityAccess.user_id).where(col(EntityAccess.entity_id).in_(scope)) + ).all()) + ids |= set(session.exec( + select(CapitalAccountStatement.investor_user_id).where( + col(CapitalAccountStatement.entity_id).in_(scope) + ) + ).all()) + return ids + + +def check_target_investor_scope(admin: User, target_user_id: int, session: Session) -> None: + """403 unless the caller is internal, or the target is an investor already inside the + caller's scope. Blocks an external Administrator from pulling an out-of-scope investor + into their funds (which would otherwise widen their scope and unlock further actions).""" + allowed = visible_investor_ids(admin, session) + if allowed is None: + return + if target_user_id not in allowed: + raise HTTPException(status_code=403, detail="No access to this account") diff --git a/backend/ten31portal/config.py b/backend/ten31portal/config.py index 837edcb..0ae4ab6 100644 --- a/backend/ten31portal/config.py +++ b/backend/ten31portal/config.py @@ -5,6 +5,20 @@ import os 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 secrets that must never reach production. The app refuses to start on any of these +# (see main.lifespan) so a packaging slip cannot silently boot on a source-visible key that +# would let anyone forge a session cookie. +INSECURE_SESSION_SECRETS: frozenset[str] = frozenset({ + "", "change-me", "change-me-in-production", +}) +# Send the session cookie only over HTTPS. Off by default so local dev and the test client +# (both plain HTTP) work; the container turns it on via TEN31_SESSION_COOKIE_SECURE=true. +SESSION_COOKIE_SECURE: bool = os.getenv( + "TEN31_SESSION_COOKIE_SECURE", "false" +).lower() in ("1", "true", "yes") +# Session lifetime. Default 12 hours (was the Starlette 14-day default), so a captured cookie +# ages out in a workday rather than a fortnight. +SESSION_MAX_AGE: int = int(os.getenv("TEN31_SESSION_MAX_AGE", str(12 * 60 * 60))) 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. The same cap diff --git a/backend/ten31portal/main.py b/backend/ten31portal/main.py index c1dbdc9..74e9694 100644 --- a/backend/ten31portal/main.py +++ b/backend/ten31portal/main.py @@ -4,13 +4,16 @@ import os from contextlib import asynccontextmanager from pathlib import Path -from fastapi import FastAPI +from fastapi import FastAPI, Request from fastapi.staticfiles import StaticFiles from starlette.middleware.gzip import GZipMiddleware from starlette.middleware.sessions import SessionMiddleware from starlette.responses import FileResponse -from ten31portal.config import SESSION_SECRET +from ten31portal import config +from ten31portal.config import ( + INSECURE_SESSION_SECRETS, SESSION_COOKIE_SECURE, SESSION_MAX_AGE, SESSION_SECRET, +) from ten31portal.db_init import run_migrations from ten31portal.routers.auth_router import router as auth_router from ten31portal.routers.audit_router import router as audit_router @@ -27,14 +30,53 @@ from ten31portal.routers.capital_import_router import router as capital_import_r @asynccontextmanager async def lifespan(app: FastAPI): + # Refuse to boot on a publicly-known session secret: with one, anyone can forge a session + # cookie for any user id and bypass authentication. start.sh generates a real secret; this + # is the backstop for a misconfigured or bypassed entrypoint. + if SESSION_SECRET in INSECURE_SESSION_SECRETS or len(SESSION_SECRET) < 32: + raise RuntimeError( + "TEN31_SESSION_SECRET is unset, a known default, or too short. Set it to a random " + "value of at least 32 characters before starting the portal." + ) run_migrations() from ten31portal.storage import ensure_docs_dir ensure_docs_dir() yield +# Security headers applied to every response. The SPA loads only same-origin hashed assets and +# renders the TOTP QR as a data: image, so this CSP does not need to loosen script-src. +_SECURITY_HEADERS = { + "X-Content-Type-Options": "nosniff", + "X-Frame-Options": "DENY", + "Referrer-Policy": "no-referrer", + "Content-Security-Policy": ( + "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; " + "img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; " + "connect-src 'self'; font-src 'self' data:; form-action 'self'" + ), + "Strict-Transport-Security": "max-age=31536000; includeSubDomains", +} + + app = FastAPI(title="Ten31Portal", version="0.1.0", lifespan=lifespan) -app.add_middleware(SessionMiddleware, secret_key=SESSION_SECRET) + + +@app.middleware("http") +async def add_security_headers(request: Request, call_next): + response = await call_next(request) + for name, value in _SECURITY_HEADERS.items(): + response.headers.setdefault(name, value) + return response + + +app.add_middleware( + SessionMiddleware, + secret_key=SESSION_SECRET, + https_only=SESSION_COOKIE_SECURE, + same_site="lax", + max_age=SESSION_MAX_AGE, +) # Compress HTML/JS/CSS/JSON over the wire (the 320KB JS bundle gzips to ~80KB). Added after # SessionMiddleware so it sits outermost and compresses the final response. minimum_size skips # tiny payloads (health checks, small JSON) where compression isn't worth it. diff --git a/backend/ten31portal/routers/capital_account_router.py b/backend/ten31portal/routers/capital_account_router.py index e3a1cf9..5731a50 100644 --- a/backend/ten31portal/routers/capital_account_router.py +++ b/backend/ten31portal/routers/capital_account_router.py @@ -7,8 +7,8 @@ from sqlmodel import Session, select, col from ten31portal.audit import record_audit from ten31portal.auth import ( - accessible_entity_ids, check_administrator_scope, get_current_user, - household_user_ids, require_admin, + accessible_entity_ids, check_administrator_scope, check_target_investor_scope, + get_current_user, household_user_ids, require_admin, ) from ten31portal.database import get_session from ten31portal.models import ( @@ -135,6 +135,9 @@ def create_statement( investor = session.get(User, body.investor_user_id) if investor is None or investor.role != UserRole.investor: raise HTTPException(status_code=400, detail="investor_user_id must be an investor account") + # An external Administrator can only write statements for an investor already in their + # scope; otherwise this row would itself bring an out-of-scope investor into reach. + check_target_investor_scope(admin, body.investor_user_id, session) existing = session.exec( select(CapitalAccountStatement).where( diff --git a/backend/ten31portal/routers/capital_import_router.py b/backend/ten31portal/routers/capital_import_router.py index 6a8961e..361d485 100644 --- a/backend/ten31portal/routers/capital_import_router.py +++ b/backend/ten31portal/routers/capital_import_router.py @@ -20,7 +20,10 @@ from sqlmodel import Session, select from ten31portal import config, storage from ten31portal.audit import record_audit -from ten31portal.auth import check_administrator_scope, hash_password, require_admin +from ten31portal.auth import ( + check_administrator_scope, check_target_investor_scope, hash_password, require_admin, + visible_investor_ids, +) from ten31portal.database import get_session from ten31portal.models import ( CapitalAccountStatement, Entity, EntityAccess, User, UserRole, @@ -232,6 +235,15 @@ 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") + # An external Administrator must name one of their funds, and may only be shown matches + # against investors already inside their scope. Otherwise the name-matching would confirm + # which uploaded names correspond to real accounts (and their usernames) in other funds. + scope_ids = visible_investor_ids(admin, session) + if scope_ids is not None and entity_id is None: + raise HTTPException( + status_code=400, detail="Select one of your funds before previewing an import." + ) + try: file_bytes = storage.read_capped(file) except storage.UploadTooLarge as exc: @@ -239,6 +251,8 @@ def preview_import( wb = _open_workbook(file_bytes, password) investors = session.exec(select(User).where(User.role == UserRole.investor)).all() + if scope_ids is not None: + investors = [u for u in investors if u.id in scope_ids] by_name = {u.name.strip().lower(): u for u in investors} by_username = {u.username.strip().lower(): u for u in investors} by_extid = {u.external_investor_id: u for u in investors if u.external_investor_id} @@ -340,6 +354,9 @@ def commit_import( user = session.get(User, inv.user_id) if inv.user_id else None if user is None or user.role != UserRole.investor: raise HTTPException(status_code=400, detail="match requires a valid investor user_id.") + # An external Administrator can only match an investor already inside their scope; + # a new investor for their fund must go through the "create" action instead. + check_target_investor_scope(admin, user.id, session) if inv.external_id and not user.external_investor_id: user.external_investor_id = inv.external_id session.add(user) diff --git a/backend/ten31portal/routers/document_router.py b/backend/ten31portal/routers/document_router.py index 0c7bb06..5c90114 100644 --- a/backend/ten31portal/routers/document_router.py +++ b/backend/ten31portal/routers/document_router.py @@ -154,6 +154,8 @@ def download_document( path, media_type=doc.content_type, filename=doc.original_filename, + # K-1s and capital statements must not linger in a shared-machine browser cache. + headers={"Cache-Control": "no-store"}, ) diff --git a/backend/ten31portal/routers/entity_router.py b/backend/ten31portal/routers/entity_router.py index 8d023e3..16d9f52 100644 --- a/backend/ten31portal/routers/entity_router.py +++ b/backend/ten31portal/routers/entity_router.py @@ -7,8 +7,9 @@ from sqlmodel import Session, col, select from ten31portal.audit import record_audit from ten31portal.auth import ( - accessible_entity_ids, check_administrator_scope, get_current_user, household_user_ids, - require_entity_writer, require_internal, require_internal_or_administrator, require_writer, + accessible_entity_ids, check_administrator_scope, check_target_investor_scope, + get_current_user, household_user_ids, require_entity_writer, require_internal, + require_internal_or_administrator, require_writer, ) from ten31portal.database import get_session from ten31portal.models import ( @@ -178,6 +179,9 @@ def set_partner_exited( member = session.get(User, user_id) if member is None or member.role != UserRole.investor: raise HTTPException(status_code=400, detail="Only investor members can be marked exited") + # Marking an exit can mint an access grant (below); an external Administrator may only do + # that for an investor already inside their scope, never to pull in an out-of-fund account. + check_target_investor_scope(admin, user_id, session) access = session.exec( select(EntityAccess).where( diff --git a/backend/ten31portal/routers/user_router.py b/backend/ten31portal/routers/user_router.py index 1802219..e6194a6 100644 --- a/backend/ten31portal/routers/user_router.py +++ b/backend/ten31portal/routers/user_router.py @@ -5,8 +5,9 @@ from sqlmodel import Session, select, col from ten31portal.audit import record_audit from ten31portal.auth import ( - accessible_entity_ids, can_access_entity, check_administrator_scope, get_current_user, - hash_password, household_user_ids, require_admin, require_internal_admin, require_role, + accessible_entity_ids, can_access_entity, check_administrator_scope, + check_target_investor_scope, get_current_user, hash_password, household_user_ids, + require_admin, require_internal_admin, require_role, visible_investor_ids, ) from ten31portal.database import get_session from ten31portal.models import ( @@ -43,12 +44,15 @@ def _visible_investor_ids(scope: set[int], session: Session) -> set[int]: def _check_target_in_scope(admin: User, target: User, session: Session) -> None: """403 unless the caller is internal, or the target is an investor tied to one of the Administrator's entities. Keeps an external Administrator away from staff accounts and - from other funds' investors entirely.""" - scope = accessible_entity_ids(admin, session) - if scope is None: + from other funds' investors entirely. Delegates to the shared helper so the scope + definition can never drift between routers.""" + if target.role != UserRole.investor: + # An external Administrator may only ever act on investor accounts. + scope = accessible_entity_ids(admin, session) + if scope is not None: + raise HTTPException(status_code=403, detail="No access to this account") return - if target.role != UserRole.investor or target.id not in _visible_investor_ids(scope, session): - raise HTTPException(status_code=403, detail="No access to this account") + check_target_investor_scope(admin, target.id, session) def delete_user_cascade(session: Session, user: User) -> list[str]: @@ -106,6 +110,51 @@ def delete_user_cascade(session: Session, user: User) -> list[str]: return warnings +def _enforce_privileged_fields( + admin: User, + target: User | None, + new_role: UserRole | None, + new_is_active: bool | None, + session: Session, +) -> None: + """Guard role/is_active against privilege escalation and admin tampering. + + `role` and `is_active` are not ordinary profile fields: writing them changes who can do + what. Without this, any internal admin (operations/cfo) could PATCH their own role to + approver, mint new approver accounts, or deactivate the Service Admin. Rules: + + - Only an approver may create or confer the `approver` role, or change an existing + approver's role/status (approver holds final valuation sign-off — the one control + operations is defined not to have). + - The Service Admin's role and status are fixed via the API. + - No account may change its own role or active status. + + `target` is None on create. Internal non-approver admins keep managing non-approver + accounts (investors, viewers, fund admins, and each other) unchanged. + """ + changing_role = new_role is not None and (target is None or new_role != target.role) + changing_active = ( + new_is_active is not None and target is not None and new_is_active != target.is_active + ) + if target is not None and (changing_role or changing_active): + if target.is_service_admin: + raise HTTPException( + status_code=403, detail="The Service Admin's role and status are fixed." + ) + if target.id == admin.id: + raise HTTPException( + status_code=403, detail="You cannot change your own role or status." + ) + touches_approver = (new_role == UserRole.approver) or ( + target is not None and target.role == UserRole.approver and (changing_role or changing_active) + ) + if touches_approver and admin.role != UserRole.approver: + raise HTTPException( + status_code=403, + detail="Only a Managing Partner (approver) can manage approver accounts.", + ) + + @router.get("/access-matrix") def access_matrix( admin: User = Depends(require_admin), @@ -147,6 +196,10 @@ def grant_access( raise HTTPException(status_code=404, detail="User not found") if admin.role == UserRole.fund_administrator and user.role != UserRole.investor: raise HTTPException(status_code=403, detail="Administrators manage investor accounts only") + # An external Administrator may only add funds for an investor already inside their scope; + # otherwise granting access would pull an out-of-fund investor in and widen the caller's + # reach. Internal staff pass through. New investors are onboarded via create_user instead. + _check_target_in_scope(admin, user, session) if session.get(Entity, entity_id) is None: raise HTTPException(status_code=404, detail="Entity not found") existing = session.exec( @@ -379,6 +432,8 @@ def create_user( raise HTTPException( status_code=400, detail="Select at least one of your funds for this investor" ) + # Internal callers may pick any role here; block escalation to approver by non-approvers. + _enforce_privileged_fields(admin, None, body.role, None, session) if session.exec(select(User).where(User.username == body.username)).first(): raise HTTPException(status_code=409, detail="Username already taken") if body.email and session.exec(select(User).where(User.email == body.email)).first(): @@ -413,6 +468,9 @@ def update_user( if user is None: raise HTTPException(status_code=404, detail="User not found") _check_target_in_scope(admin, user, session) + # role/is_active are privileged: block self-escalation, approver tampering, and any change + # to the Service Admin before applying the field writes below. + _enforce_privileged_fields(admin, user, body.role, body.is_active, session) changes = body.model_dump(exclude_unset=True) entity_ids = changes.pop("entity_ids", None) diff --git a/backend/tests/test_security_hardening.py b/backend/tests/test_security_hardening.py new file mode 100644 index 0000000..ec89d71 --- /dev/null +++ b/backend/tests/test_security_hardening.py @@ -0,0 +1,256 @@ +"""Regression tests for the pre-release security audit fixes. + +Covers three classes of finding: +- Cross-tenant investor takeover by an external Administrator (scope-check on every write that + can mint an access grant or a capital statement for an arbitrary investor). +- Internal privilege escalation via role/is_active mass-assignment on the user endpoints. +- Server-side enforcement of the forced password change (not just the browser modal). +""" + +from datetime import date + +from sqlmodel import select + +from ten31portal.models import ( + CapitalAccountStatement, Entity, EntityAccess, EntityType, User, UserRole, +) +from tests.conftest import make_user + + +def _login(client, username, password="password123"): + client.post("/api/auth/logout") + resp = client.post("/api/auth/login", json={"login": username, "password": password}) + assert resp.status_code == 200, resp.text + return resp + + +def _two_funds(session): + """Fund A (managed by `fundadmin`) and Fund B. lp_a is in A, lp_b is in B (out of scope).""" + fund_a = Entity(name="Fund A", type=EntityType.fund) + fund_b = Entity(name="Fund B", type=EntityType.fund) + session.add(fund_a) + session.add(fund_b) + session.commit() + + admin = make_user(session, username="fundadmin", role=UserRole.fund_administrator, + name="Outside Administrator") + session.add(EntityAccess(user_id=admin.id, entity_id=fund_a.id)) + lp_a = make_user(session, username="lp-a", role=UserRole.investor, name="LP Alpha") + session.add(EntityAccess(user_id=lp_a.id, entity_id=fund_a.id)) + lp_b = make_user(session, username="lp-b", role=UserRole.investor, name="LP Beta") + session.add(EntityAccess(user_id=lp_b.id, entity_id=fund_b.id)) + session.commit() + return fund_a, fund_b, admin, lp_a, lp_b + + +# --- P0-1: cross-tenant investor takeover ------------------------------------------------ + +def test_administrator_cannot_grant_out_of_scope_investor(client, session, approver): + """The takeover chain: grant an out-of-fund investor access to my fund, which would pull + them into scope and unlock reset-password. The grant itself must be refused.""" + fund_a, fund_b, admin, lp_a, lp_b = _two_funds(session) + _login(client, "fundadmin") + + resp = client.put(f"/api/users/{lp_b.id}/access/{fund_a.id}") + assert resp.status_code == 403, resp.text + # No EntityAccess row was minted, so lp_b never entered the Administrator's scope. + rows = session.exec( + select(EntityAccess).where( + EntityAccess.user_id == lp_b.id, EntityAccess.entity_id == fund_a.id + ) + ).all() + assert rows == [] + # And the follow-on reset-password stays blocked. + assert client.post(f"/api/users/{lp_b.id}/reset-password", + json={"password": "attacker-set-pw"}).status_code == 403 + + +def test_administrator_cannot_create_statement_for_out_of_scope_investor(client, session, approver): + fund_a, fund_b, admin, lp_a, lp_b = _two_funds(session) + _login(client, "fundadmin") + + resp = client.post("/api/capital-accounts", json={ + "entity_id": fund_a.id, + "investor_user_id": lp_b.id, + "as_of_date": "2026-03-31", + "commitment_dollars": 1000, "beginning_balance_dollars": 0, + "contributions_dollars": 1000, "distributions_dollars": 0, + "ending_balance_dollars": 1000, + }) + assert resp.status_code == 403, resp.text + assert session.exec( + select(CapitalAccountStatement).where( + CapitalAccountStatement.investor_user_id == lp_b.id + ) + ).all() == [] + + +def test_administrator_cannot_mint_exit_grant_for_out_of_scope_investor(client, session, approver): + fund_a, fund_b, admin, lp_a, lp_b = _two_funds(session) + _login(client, "fundadmin") + + resp = client.put(f"/api/entities/{fund_a.id}/partners/{lp_b.id}/exited", + json={"exited_on": "2026-06-30"}) + assert resp.status_code == 403, resp.text + assert session.exec( + select(EntityAccess).where( + EntityAccess.user_id == lp_b.id, EntityAccess.entity_id == fund_a.id + ) + ).all() == [] + + +def test_administrator_can_still_manage_in_scope_investor(client, session, approver): + """Positive control: the legitimate in-scope path is untouched.""" + fund_a, fund_b, admin, lp_a, lp_b = _two_funds(session) + _login(client, "fundadmin") + + # lp_a is already in fund_a, so a statement and a password reset both succeed. + assert client.post("/api/capital-accounts", json={ + "entity_id": fund_a.id, "investor_user_id": lp_a.id, "as_of_date": "2026-03-31", + "commitment_dollars": 500, "beginning_balance_dollars": 0, + "contributions_dollars": 500, "distributions_dollars": 0, + "ending_balance_dollars": 500, + }).status_code == 201 + assert client.post(f"/api/users/{lp_a.id}/reset-password", + json={"password": "a-fresh-password"}).status_code == 200 + + +# --- P0-2: internal privilege escalation via mass-assignment ----------------------------- + +def test_operations_cannot_self_promote_to_approver(client, session): + ops = make_user(session, username="ops", role=UserRole.operations) + _login(client, "ops") + + resp = client.patch(f"/api/users/{ops.id}", json={"role": "approver"}) + assert resp.status_code == 403, resp.text + session.refresh(ops) + assert ops.role == UserRole.operations + + +def test_operations_cannot_create_approver(client, session): + make_user(session, username="ops", role=UserRole.operations) + _login(client, "ops") + + resp = client.post("/api/users", json={ + "name": "Sneaky", "username": "sneaky", "password": "password123", "role": "approver", + }) + assert resp.status_code == 403, resp.text + + +def test_operations_cannot_deactivate_service_admin(client, session): + make_user(session, username="ops", role=UserRole.operations) + svc = make_user(session, username="svc", role=UserRole.approver, is_service_admin=True) + _login(client, "ops") + + resp = client.patch(f"/api/users/{svc.id}", json={"is_active": False}) + assert resp.status_code == 403, resp.text + session.refresh(svc) + assert svc.is_active is True + + +def test_operations_cannot_disable_an_approver(client, session): + make_user(session, username="ops", role=UserRole.operations) + boss = make_user(session, username="boss", role=UserRole.approver) + _login(client, "ops") + + resp = client.patch(f"/api/users/{boss.id}", json={"is_active": False}) + assert resp.status_code == 403, resp.text + + +def test_approver_can_still_manage_roles(client, session): + """Positive control: an approver retains full role management.""" + approver = make_user(session, username="chief", role=UserRole.approver) + ops = make_user(session, username="ops", role=UserRole.operations) + _login(client, "chief") + + assert client.patch(f"/api/users/{ops.id}", json={"role": "approver"}).status_code == 200 + session.refresh(ops) + assert ops.role == UserRole.approver + + +def test_operations_can_still_edit_ordinary_fields(client, session): + """Non-privileged edits by an internal admin keep working.""" + make_user(session, username="ops", role=UserRole.operations) + viewer = make_user(session, username="v", role=UserRole.viewer, name="Old Name") + _login(client, "ops") + + assert client.patch(f"/api/users/{viewer.id}", json={"name": "New Name"}).status_code == 200 + session.refresh(viewer) + assert viewer.name == "New Name" + + +# --- P0-3: server-side forced password change -------------------------------------------- + +def test_must_change_password_blocks_api_until_rotated(client, session): + make_user(session, username="lp", role=UserRole.investor, password="Ten31Portal", + must_change_password=True) + resp = client.post("/api/auth/login", json={"login": "lp", "password": "Ten31Portal"}) + assert resp.status_code == 200, resp.text + + # /me is allowed (the client needs it to learn it must prompt), but data endpoints are not. + assert client.get("/api/auth/me").status_code == 200 + assert client.get("/api/entities").status_code == 403 + assert client.get("/api/documents").status_code == 403 + + # Rotating the password lifts the block. + changed = client.post("/api/auth/change-password", json={ + "current_password": "Ten31Portal", "new_password": "my-own-strong-pw", + }) + assert changed.status_code == 200, changed.text + assert client.get("/api/entities").status_code == 200 + + +# --- P1/P2: transport and header hardening ----------------------------------------------- + +def test_security_headers_present(client): + resp = client.get("/api/health") + assert resp.headers["X-Content-Type-Options"] == "nosniff" + assert resp.headers["X-Frame-Options"] == "DENY" + assert "default-src 'self'" in resp.headers["Content-Security-Policy"] + assert "frame-ancestors 'none'" in resp.headers["Content-Security-Policy"] + + +def test_session_cookie_is_hardened(client, session): + make_user(session, username="u", role=UserRole.approver) + resp = client.post("/api/auth/login", json={"login": "u", "password": "password123"}) + assert resp.status_code == 200 + set_cookie = resp.headers["set-cookie"].lower() + assert "httponly" in set_cookie + assert "samesite=lax" in set_cookie + assert "max-age=" in set_cookie # bounded lifetime, not the 14-day default + + +def test_download_is_no_store(client, session, tmp_path, monkeypatch): + """Document downloads must not be cached on shared machines.""" + from ten31portal import storage + from ten31portal.models import Document, DocumentCategory, Entity, EntityType + + monkeypatch.setattr(storage, "DOCS_DIR", str(tmp_path)) + (tmp_path / "file.pdf").write_bytes(b"%PDF-1.4 test") + ent = Entity(name="F", type=EntityType.fund) + session.add(ent) + session.commit() + doc = Document(entity_id=ent.id, category=DocumentCategory.k1, title="K1", + original_filename="k1.pdf", content_type="application/pdf", + size_bytes=13, storage_path="file.pdf", uploaded_by=None) + session.add(doc) + session.commit() + make_user(session, username="staff", role=UserRole.approver) + _login(client, "staff") + + resp = client.get(f"/api/documents/{doc.id}/download") + assert resp.status_code == 200, resp.text + assert resp.headers["Cache-Control"] == "no-store" + + +# --- P2-9: import preview must not leak cross-fund investors ------------------------------ + +def test_external_admin_preview_requires_a_fund(client, session, approver): + _two_funds(session) + _login(client, "fundadmin") + # No entity_id: refused before any name-matching can leak which accounts exist. + resp = client.post( + "/api/import/capital-accounts/preview", + files={"file": ("x.xlsx", b"not-a-real-workbook", "application/octet-stream")}, + ) + assert resp.status_code == 400, resp.text diff --git a/deploy/Dockerfile b/deploy/Dockerfile index bee02d7..2fd5278 100644 --- a/deploy/Dockerfile +++ b/deploy/Dockerfile @@ -42,6 +42,8 @@ RUN groupadd --gid 10001 appuser \ ENV TEN31_DB_PATH=/data/portal.db ENV TEN31_DOCS_DIR=/data/documents ENV TEN31_SESSION_SECRET=change-me +# Served behind the platform's TLS-terminating proxy, so the session cookie is Secure. +ENV TEN31_SESSION_COOKIE_SECURE=true EXPOSE 8000 diff --git a/deploy/start.sh b/deploy/start.sh index f77cab2..64752bd 100644 --- a/deploy/start.sh +++ b/deploy/start.sh @@ -1,16 +1,24 @@ #!/bin/sh set -e -# Generate session secret if not set or default -if [ "$TEN31_SESSION_SECRET" = "change-me" ]; then - if [ -f /data/.session-secret ]; then - export TEN31_SESSION_SECRET=$(cat /data/.session-secret) - else - export TEN31_SESSION_SECRET=$(python3 -c "import secrets; print(secrets.token_hex(32))") - echo "$TEN31_SESSION_SECRET" > /data/.session-secret - chmod 600 /data/.session-secret - fi -fi +# Generate a session secret unless the operator supplied a real one. Any unset/empty value or a +# known placeholder counts as "not set" — the app also refuses to boot on these (see +# main.lifespan), so the two checks must agree on the full placeholder list. +case "$TEN31_SESSION_SECRET" in + ""|change-me|change-me-in-production) + if [ -f /data/.session-secret ]; then + export TEN31_SESSION_SECRET=$(cat /data/.session-secret) + else + export TEN31_SESSION_SECRET=$(python3 -c "import secrets; print(secrets.token_hex(32))") + echo "$TEN31_SESSION_SECRET" > /data/.session-secret + chmod 600 /data/.session-secret + fi + ;; +esac + +# The platform terminates TLS and reverse-proxies to us, so the session cookie is served over +# HTTPS to the client: mark it Secure. Default on here; overridable for a plain-HTTP setup. +export TEN31_SESSION_COOKIE_SECURE="${TEN31_SESSION_COOKIE_SECURE:-true}" # Create first approver on first boot if no users exist if [ ! -f /data/.initialized ]; then @@ -58,10 +66,16 @@ LOG_LEVEL="${TEN31_LOG_LEVEL:-info}" # 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. +# --proxy-headers lets uvicorn read X-Forwarded-For from the platform's reverse proxy so the +# login rate limiter keys on the real client IP, not the single proxy address (otherwise one +# source's failures throttle every user). The container port is only reachable via that proxy, +# so trusting forwarded headers from any upstream is safe here. 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" + uvicorn ten31portal.main:app --host 0.0.0.0 --port 8000 --log-level "$LOG_LEVEL" \ + --proxy-headers --forwarded-allow-ips="*" else - exec uvicorn ten31portal.main:app --host 0.0.0.0 --port 8000 --log-level "$LOG_LEVEL" + exec uvicorn ten31portal.main:app --host 0.0.0.0 --port 8000 --log-level "$LOG_LEVEL" \ + --proxy-headers --forwarded-allow-ips="*" fi diff --git a/deploy/startos/install/versions/index.ts b/deploy/startos/install/versions/index.ts index ac4a2ad..d1b943a 100644 --- a/deploy/startos/install/versions/index.ts +++ b/deploy/startos/install/versions/index.ts @@ -1,5 +1,6 @@ -export { v_0_2_45 as current } from './v_0_2_45' +export { v_0_2_46 as current } from './v_0_2_46' import { v_0_1_0 } from './v_0_1_0' +import { v_0_2_45 } from './v_0_2_45' import { v_0_2_40 } from './v_0_2_40' import { v_0_2_41 } from './v_0_2_41' import { v_0_2_42 } from './v_0_2_42' @@ -44,4 +45,4 @@ import { v_0_2_36 } from './v_0_2_36' import { v_0_2_37 } from './v_0_2_37' import { v_0_2_38 } from './v_0_2_38' import { v_0_2_39 } from './v_0_2_39' -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, v_0_2_26, v_0_2_27, v_0_2_28, v_0_2_29, v_0_2_30, v_0_2_31, v_0_2_32, v_0_2_33, v_0_2_34, v_0_2_35, v_0_2_36, v_0_2_37, v_0_2_38, v_0_2_39, v_0_2_40, v_0_2_41, v_0_2_42, v_0_2_43, v_0_2_44] +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, v_0_2_26, v_0_2_27, v_0_2_28, v_0_2_29, v_0_2_30, v_0_2_31, v_0_2_32, v_0_2_33, v_0_2_34, v_0_2_35, v_0_2_36, v_0_2_37, v_0_2_38, v_0_2_39, v_0_2_40, v_0_2_41, v_0_2_42, v_0_2_43, v_0_2_44, v_0_2_45] diff --git a/deploy/startos/install/versions/v_0_2_46.ts b/deploy/startos/install/versions/v_0_2_46.ts new file mode 100644 index 0000000..348f622 --- /dev/null +++ b/deploy/startos/install/versions/v_0_2_46.ts @@ -0,0 +1,19 @@ +import { VersionInfo } from '@start9labs/start-sdk' + +export const v_0_2_46 = VersionInfo.of({ + version: '0.2.46:0', + releaseNotes: { + en_US: + 'Security hardening ahead of the investor release. External Administrators can now only ' + + 'act on investors already in their own funds (closing a cross-fund account-takeover path). ' + + 'Role and active-status changes are restricted so staff cannot escalate their own ' + + 'permissions or alter the Service Admin. The forced first-login password change is now ' + + 'enforced by the server, not just the browser. Session cookies are marked Secure with a ' + + 'shorter lifetime, standard security headers are set on every response, and login ' + + 'throttling now sees the real client address behind the platform proxy.', + }, + migrations: { + up: async ({ effects }) => {}, + down: async ({ effects }) => {}, + }, +}) diff --git a/frontend/src/version.ts b/frontend/src/version.ts index 4798da4..afd6171 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.45"; +export const APP_VERSION = "0.2.46";