0.2.46: security hardening (external-fund scope, role guards, forced password rotation, headers)

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.
This commit is contained in:
Jonathan Kirkwood
2026-08-28 09:53:16 -05:00
parent 858bbe10da
commit 3a4b7dfd8b
15 changed files with 795 additions and 31 deletions
+283
View File
@@ -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.
+50 -1
View File
@@ -8,7 +8,9 @@ from fastapi import Depends, HTTPException, Request
from sqlmodel import Session, select, col from sqlmodel import Session, select, col
from ten31portal.database import get_session 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() ph = PasswordHasher()
@@ -24,6 +26,16 @@ def verify_password(password: str, password_hash: str) -> bool:
return False 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: def get_current_user(request: Request, session: Session = Depends(get_session)) -> User:
"""FastAPI dependency: extract user from session cookie.""" """FastAPI dependency: extract user from session cookie."""
user_id = request.session.get("user_id") 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) user = session.get(User, user_id)
if user is None or not user.is_active: if user is None or not user.is_active:
raise HTTPException(status_code=401, detail="Not authenticated") 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 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): 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") 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")
+14
View File
@@ -5,6 +5,20 @@ import os
DB_PATH: str = os.getenv("TEN31_DB_PATH", "/data/ten31portal/portal.db") 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")
# 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") 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. The same cap # unbounded upload could fill the disk and take the portal down. Default 50 MB. The same cap
+45 -3
View File
@@ -4,13 +4,16 @@ import os
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from pathlib import Path from pathlib import Path
from fastapi import FastAPI from fastapi import FastAPI, Request
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from starlette.middleware.gzip import GZipMiddleware from starlette.middleware.gzip import GZipMiddleware
from starlette.middleware.sessions import SessionMiddleware from starlette.middleware.sessions import SessionMiddleware
from starlette.responses import FileResponse 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.db_init import run_migrations
from ten31portal.routers.auth_router import router as auth_router from ten31portal.routers.auth_router import router as auth_router
from ten31portal.routers.audit_router import router as audit_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 @asynccontextmanager
async def lifespan(app: FastAPI): 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() run_migrations()
from ten31portal.storage import ensure_docs_dir from ten31portal.storage import ensure_docs_dir
ensure_docs_dir() ensure_docs_dir()
yield 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 = 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 # 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 # 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. # tiny payloads (health checks, small JSON) where compression isn't worth it.
@@ -7,8 +7,8 @@ from sqlmodel import Session, select, col
from ten31portal.audit import record_audit from ten31portal.audit import record_audit
from ten31portal.auth import ( from ten31portal.auth import (
accessible_entity_ids, check_administrator_scope, get_current_user, accessible_entity_ids, check_administrator_scope, check_target_investor_scope,
household_user_ids, require_admin, get_current_user, household_user_ids, require_admin,
) )
from ten31portal.database import get_session from ten31portal.database import get_session
from ten31portal.models import ( from ten31portal.models import (
@@ -135,6 +135,9 @@ def create_statement(
investor = session.get(User, body.investor_user_id) investor = session.get(User, body.investor_user_id)
if investor is None or investor.role != UserRole.investor: if investor is None or investor.role != UserRole.investor:
raise HTTPException(status_code=400, detail="investor_user_id must be an investor account") 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( existing = session.exec(
select(CapitalAccountStatement).where( select(CapitalAccountStatement).where(
@@ -20,7 +20,10 @@ from sqlmodel import Session, select
from ten31portal import config, storage from ten31portal import config, storage
from ten31portal.audit import record_audit 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.database import get_session
from ten31portal.models import ( from ten31portal.models import (
CapitalAccountStatement, Entity, EntityAccess, User, UserRole, 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: 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")
# 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: try:
file_bytes = storage.read_capped(file) file_bytes = storage.read_capped(file)
except storage.UploadTooLarge as exc: except storage.UploadTooLarge as exc:
@@ -239,6 +251,8 @@ def preview_import(
wb = _open_workbook(file_bytes, password) 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()
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_name = {u.name.strip().lower(): u for u in investors}
by_username = {u.username.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} 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 user = session.get(User, inv.user_id) if inv.user_id else None
if user is None or user.role != UserRole.investor: if user is None or user.role != UserRole.investor:
raise HTTPException(status_code=400, detail="match requires a valid investor user_id.") 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: if inv.external_id and not user.external_investor_id:
user.external_investor_id = inv.external_id user.external_investor_id = inv.external_id
session.add(user) session.add(user)
@@ -154,6 +154,8 @@ def download_document(
path, path,
media_type=doc.content_type, media_type=doc.content_type,
filename=doc.original_filename, filename=doc.original_filename,
# K-1s and capital statements must not linger in a shared-machine browser cache.
headers={"Cache-Control": "no-store"},
) )
+6 -2
View File
@@ -7,8 +7,9 @@ from sqlmodel import Session, col, select
from ten31portal.audit import record_audit from ten31portal.audit import record_audit
from ten31portal.auth import ( from ten31portal.auth import (
accessible_entity_ids, check_administrator_scope, get_current_user, household_user_ids, accessible_entity_ids, check_administrator_scope, check_target_investor_scope,
require_entity_writer, require_internal, require_internal_or_administrator, require_writer, 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.database import get_session
from ten31portal.models import ( from ten31portal.models import (
@@ -178,6 +179,9 @@ def set_partner_exited(
member = session.get(User, user_id) member = session.get(User, user_id)
if member is None or member.role != UserRole.investor: if member is None or member.role != UserRole.investor:
raise HTTPException(status_code=400, detail="Only investor members can be marked exited") 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( access = session.exec(
select(EntityAccess).where( select(EntityAccess).where(
+65 -7
View File
@@ -5,8 +5,9 @@ from sqlmodel import Session, select, col
from ten31portal.audit import record_audit from ten31portal.audit import record_audit
from ten31portal.auth import ( from ten31portal.auth import (
accessible_entity_ids, can_access_entity, check_administrator_scope, get_current_user, accessible_entity_ids, can_access_entity, check_administrator_scope,
hash_password, household_user_ids, require_admin, require_internal_admin, require_role, 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.database import get_session
from ten31portal.models import ( 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: 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 """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 Administrator's entities. Keeps an external Administrator away from staff accounts and
from other funds' investors entirely.""" from other funds' investors entirely. Delegates to the shared helper so the scope
scope = accessible_entity_ids(admin, session) definition can never drift between routers."""
if scope is None: 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 return
if target.role != UserRole.investor or target.id not in _visible_investor_ids(scope, session): check_target_investor_scope(admin, target.id, session)
raise HTTPException(status_code=403, detail="No access to this account")
def delete_user_cascade(session: Session, user: User) -> list[str]: 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 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") @router.get("/access-matrix")
def access_matrix( def access_matrix(
admin: User = Depends(require_admin), admin: User = Depends(require_admin),
@@ -147,6 +196,10 @@ def grant_access(
raise HTTPException(status_code=404, detail="User not found") raise HTTPException(status_code=404, detail="User not found")
if admin.role == UserRole.fund_administrator and user.role != UserRole.investor: if admin.role == UserRole.fund_administrator and user.role != UserRole.investor:
raise HTTPException(status_code=403, detail="Administrators manage investor accounts only") 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: if 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")
existing = session.exec( existing = session.exec(
@@ -379,6 +432,8 @@ def create_user(
raise HTTPException( raise HTTPException(
status_code=400, detail="Select at least one of your funds for this investor" 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(): if session.exec(select(User).where(User.username == body.username)).first():
raise HTTPException(status_code=409, detail="Username already taken") raise HTTPException(status_code=409, detail="Username already taken")
if body.email and session.exec(select(User).where(User.email == body.email)).first(): 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: if user is None:
raise HTTPException(status_code=404, detail="User not found") raise HTTPException(status_code=404, detail="User not found")
_check_target_in_scope(admin, user, session) _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) changes = body.model_dump(exclude_unset=True)
entity_ids = changes.pop("entity_ids", None) entity_ids = changes.pop("entity_ids", None)
+256
View File
@@ -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
+2
View File
@@ -42,6 +42,8 @@ RUN groupadd --gid 10001 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
# Served behind the platform's TLS-terminating proxy, so the session cookie is Secure.
ENV TEN31_SESSION_COOKIE_SECURE=true
EXPOSE 8000 EXPOSE 8000
+26 -12
View File
@@ -1,16 +1,24 @@
#!/bin/sh #!/bin/sh
set -e set -e
# Generate session secret if not set or default # Generate a session secret unless the operator supplied a real one. Any unset/empty value or a
if [ "$TEN31_SESSION_SECRET" = "change-me" ]; then # known placeholder counts as "not set" — the app also refuses to boot on these (see
if [ -f /data/.session-secret ]; then # main.lifespan), so the two checks must agree on the full placeholder list.
export TEN31_SESSION_SECRET=$(cat /data/.session-secret) case "$TEN31_SESSION_SECRET" in
else ""|change-me|change-me-in-production)
export TEN31_SESSION_SECRET=$(python3 -c "import secrets; print(secrets.token_hex(32))") if [ -f /data/.session-secret ]; then
echo "$TEN31_SESSION_SECRET" > /data/.session-secret export TEN31_SESSION_SECRET=$(cat /data/.session-secret)
chmod 600 /data/.session-secret else
fi export TEN31_SESSION_SECRET=$(python3 -c "import secrets; print(secrets.token_hex(32))")
fi 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 # Create first approver on first boot if no users exist
if [ ! -f /data/.initialized ]; then 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 # 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 # (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. # 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 if [ "$(id -u)" = "0" ]; then
chown -R 10001:10001 /data chown -R 10001:10001 /data
exec setpriv --reuid=10001 --regid=10001 --clear-groups \ 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 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 fi
+3 -2
View File
@@ -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_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_40 } from './v_0_2_40'
import { v_0_2_41 } from './v_0_2_41' import { v_0_2_41 } from './v_0_2_41'
import { v_0_2_42 } from './v_0_2_42' 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_37 } from './v_0_2_37'
import { v_0_2_38 } from './v_0_2_38' import { v_0_2_38 } from './v_0_2_38'
import { v_0_2_39 } from './v_0_2_39' 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]
@@ -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 }) => {},
},
})
+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.45"; export const APP_VERSION = "0.2.46";