# Security policy ## Reporting a vulnerability Please report security issues **privately** — do not open a public issue for an unpatched vulnerability. Use either private channel: - **Email (preferred):** [builder@massing.cloud](mailto:builder@massing.cloud). PGP available on request. - **GitHub:** a [private security advisory](https://github.com/ibuilder/massing/security/advisories/new) (via the repository's **Security → Advisories → Report a vulnerability** button). We aim to acknowledge within a few days and to ship a fix or mitigation promptly. Please include steps to reproduce, affected version/commit, and impact. ## Threat model The platform ships in two postures: - **Local / desktop / demo (default):** single operator, open by design — RBAC is **off** so local flows just work. Run it on a trusted machine / network. - **Team / cloud (multi-user):** turn on access control and set the secrets below. With RBAC on, every request to project, finance, connection, settings, and admin surfaces requires an authenticated identity (a defense-in-depth gate enforces this even if an endpoint lacks its own role check), and each project-scoped route is authorized by the caller's project role. ## Production hardening checklist Set these environment variables for a team/cloud deployment: | Variable | Purpose | |---|---| | `AEC_RBAC=1` | **Enforce** role-based access control (viewer < reviewer < editor < admin). | | `AEC_AUTH_SECRET=` | Sign auth tokens with a private secret. **Required** — without it tokens use a public dev secret and are forgeable (the app logs a warning at startup). | | `AEC_API_KEY=` | Optional admin bearer for automation/CI. | | `AEC_REQUIRE_SECRET=1` | **Refuse to start** if `AEC_AUTH_SECRET` is unset (fail-closed for real deployments). | | `AEC_HSTS=1` | Emit `Strict-Transport-Security` (only when served over HTTPS). | | `AEC_COOKIE_SECURE=1` | Force the `Secure` flag on the auth cookie (auto-on over HTTPS / behind a TLS proxy). | | `AEC_CSP=1` | Enforce a strict resource Content-Security-Policy (or set `AEC_CSP=` to supply your own). Default is framing-only. | | `AEC_SIGNED_URL_TTL=3600` | Lifetime (seconds) of signed download URLs for `model.frag` / attachments. | | `AEC_CORS_ORIGINS=https://app.example.com` | Lock CORS to your web origin (dev default is `http://localhost:5173`). | | `AEC_MAX_UPLOAD_MB=1024` | Cap request body size (oversized uploads → `413`). | | `AEC_LOGIN_MAX_FAILS` / `AEC_LOGIN_WINDOW_SEC` | Login brute-force lockout (default 8 fails / 5 min → `429`). | | `AEC_RATE_LIMIT_RPM=` (+ `AEC_REDIS_URL`) | Per-IP rate limiting (multi-worker via Redis). | | `AEC_REDIS_URL=redis://redis:6379/0` | Shares the rate-limit **and** login-lockout counters across workers (the API runs multi-worker). Fail-open: any Redis error falls back to per-process counters. | | `AEC_IFC_CODE_TIMEOUT=5` | Wall-clock budget (seconds) for a sandboxed `execute_ifc_code` snippet. Only relevant when `AEC_ALLOW_IFC_CODE=1`. | | `AEC_WEBHOOK_ALLOW_PRIVATE=0` | **Set for hosted/multi-tenant.** Refuses webhook targets that resolve to private/loopback addresses (blocks cloud-metadata + intranet probing). Default `1` suits on-prem/LAN listeners. | | `AEC_TRUST_XUSER` | **Leave unset in production.** The `X-User` header is a dev-only impersonation shim, honored only when RBAC is off or this flag is set. | | `AEC_ADMIN_EMAILS=ops@example.com` | **Required with RBAC on** for platform-admin operations: platform Settings, the audit feed, user management, cost-database vintage imports, the firm standards library, and recording professional licences. With RBAC on and this unset, no account can perform them (a legacy global-`admin` account still works, for back-compat). It was previously undocumented here. | | `AEC_ESIGN_WEBHOOK_SECRET=` | **Set if you use the e-signature bridge.** Verifies an HMAC-SHA256 signature over the raw `POST /esign/webhook` body. Left unset the endpoint stays open (rate-limited, size-capped, and every audit row it writes records `signature_verified: false`) — it is the one anonymous surface that writes to the audit trail. | | `AEC_SEAL_ALLOW_PROFILE` | **Leave unset.** Re-enables the legacy caller-supplied seal identity on `POST /pdf/seal`, which allows an authenticated user to seal under another person's name and licence number. | The bundled `docker-compose.prod.yml` sets these (RBAC, require-secret, HSTS, secure cookie, strict CSP, Redis) and ships a `redis` service; you only supply the secrets in `.env` (`AEC_AUTH_SECRET`, `POSTGRES_PASSWORD`, `S3_*`). ### Professional seals need a provisioning step With RBAC on, `POST /pdf/seal` will not seal until two things exist, and neither is created automatically: 1. **A platform admin** — set `AEC_ADMIN_EMAILS` (above), otherwise nobody can record a licence. 2. **A licence record per licensee** — `POST /admin/licenses` with `{user, name_on_seal, license_no, state, discipline?, expiration?}`. The seal text is rendered from this row, so the name and number on a sealed document cannot come from the request. Licences are deliberately **not** self-served: a user asserting their own licence number carries exactly as much weight as the free-text field this replaced. The admin who records it is stored in `verified_by`. Sealing then also requires the licensee to re-enter their password (`POST /auth/step-up`, a single-use, 5-minute single-action assertion). That is not a UX preference. A seal attests that a named licensed human was in *responsible charge* of the work — a personal legal act that cannot be delegated to software — and a bearer token identifies a session, not a person, so any process holding one could otherwise emit sealed documents in the licensee's name. Automation (`AEC_API_KEY`) is refused outright. An expired licence refuses with `409`. Single-operator/desktop deployments (RBAC off, or `AEC_LOCAL_MODE=1`) are unaffected: they have no accounts and no passwords, so the legacy seal fields remain the only workable path there. See [the threat model](docs/security/threat-model.md) for the failure modes these controls address. ## Schema migrations There is **no Alembic** — by design. The schema is partly **config-driven**: each GC-portal module (`module.json`) registers its own `mod_` table at startup, so the table set isn't fixed in code. On boot `init_db()` runs an **additive, dbDelta-style sync**: `create_all` (new tables, including the dynamic module tables) → `_ensure_columns` (ALTER-ADD any model column missing from an existing table) → `_ensure_indexes` (backfill new indexes). It is **additive only** — it never drops or retypes a column, so deploying a newer build over an existing Postgres/SQLite DB is safe and automatic. This is covered by `test_migrate.py`. **Non-additive changes** (dropping/renaming/retyping a column, backfilling data, adding a NOT-NULL column with no default) are **not** handled automatically — run a one-off SQL migration against the DB during the deploy for those. Take a backup first; the additive sync intentionally won't destroy data. ## Built-in protections - **Identity:** signed bearer tokens / httpOnly `samesite=lax` cookie; the `X-User` header is never trusted in production. Accounts can be deactivated (token revocation takes effect immediately). - **Authorization:** project-scoped RBAC on read/write routes + a global gate that blocks anonymous access to protected prefixes when RBAC is on. Attachment downloads verify project membership (no IDOR). - **Response headers:** `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy`, a `Content-Security-Policy` (framing-only by default; opt-in strict resource policy), optional HSTS. - **Direct downloads:** `model.frag` and attachments accept short-lived **HMAC-signed URLs** as an alternative to a session (for QR share / worker fetch / deep links); the auth cookie is `Secure` over HTTPS. - **Signed share links (disposition & investor portal):** two intentionally-anonymous surfaces, both requiring a valid **HMAC-signed URL even when RBAC is off**, read-only, scoped to one project + record (no id-swapping), and rate-limited; minting a link requires a project member (`viewer`): - the **public listing** (`GET /projects/{id}/listings/{lid}/public`) returns **only owner-authored public fields** (price, description, beds/baths, tour link — never internal financials like NOI/cap); - the **investor statement** (`GET /projects/{id}/investors/{iid}/statement.public.pdf`) serves that one investor's capital-account statement PDF for the no-login LP portal. - **Container:** the API image runs as a **non-root user** (`appuser`, uid 10001). - **Input / data:** Pydantic-validated request models; SQLAlchemy parameterized queries; the data-source SQL browser is **read-only** (single SELECT/WITH, no DDL/DML, row-capped); storage keys are containment-checked (no path traversal) and upload filenames sanitized. - **Abuse limits:** request body-size cap, login lockout, optional per-IP rate limiting, bounded compute (e.g. Monte Carlo iterations). - **Supply chain:** CI runs `pip-audit` + `npm audit`; production npm dependencies carry no known vulnerabilities (build-only tooling is excluded from the shipped app). - **IFC is the source of truth** and the in-viewer authoring round-trips through `ifcopenshell` recipes (not arbitrary code); the optional Bonsai/Blender desktop bridge that can run Python is gated and off by default. The opt-in `execute_ifc_code` escape hatch is AST-allowlisted, denied any IO through the objects it is handed, and bounded by a wall-clock budget. - **Outbound requests** to operator-set URLs (webhooks, bridges) are scheme-validated on the initial URL *and on every redirect hop*, so a 3xx cannot walk the guard to an internal address. - **Rendered drawings** are sanitised before entering the DOM — model-derived SVG is parsed inertly and stripped of script/handlers, so a hostile IFC cannot become stored XSS. ## Disclosure We credit reporters (unless you prefer to remain anonymous) and note fixes in the [CHANGELOG](CHANGELOG.md).