Pre-release audit fixes: - External Administrators can only act on investors already in their own funds (grant_access, create_statement, set_partner_exited, import match), closing a cross-fund account-takeover chain. - Role and is_active changes are gated: no self-escalation, non-approvers cannot mint or alter approver accounts, and the Service Admin is protected. - must_change_password is enforced server-side, not just in the client. - Session cookie is Secure with a 12h max-age and SameSite; CSP and standard security headers on every response; the app refuses to boot on a known or short session secret; uvicorn runs with proxy-headers so login throttling keys on the real client IP behind the platform proxy. - Import preview no longer confirms cross-fund investors; no-store on document downloads. - Adds backend/tests/test_security_hardening.py (15 tests); full suite 76 passing. - SECURITY-AUDIT.md records the swarm audit, findings, and remediation status.
284 lines
18 KiB
Markdown
284 lines
18 KiB
Markdown
# 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.
|