Strata processes personal genetic data: consumer raw DNA exports (23andMe, AncestryDNA,
FTDNA, MyHeritage), single-sample VCFs and G25 coordinates. Genotypes are identifying, they
are permanent, and they reveal information about relatives who never consented. This document
describes the threat model and the controls in the code. File references are relative to
services/api/strata/ unless stated otherwise.
| Asset | Sensitivity | Where it lives |
|---|---|---|
| Uploaded raw DNA file | Highest | Request memory only, never persisted |
| Harmonised dosages (panel SNPs) / G25 rows | High | datasets.ciphertext or a storage blob, AES-256-GCM |
| Per-dataset data keys (DEKs) | High | dataset_keys.wrapped_key, wrapped by the KEK |
Key-encryption key (STRATA_DATA_KEY) |
Critical | Secret store / environment, never in the DB |
| Analysis results (projections, weights, p-values) | Medium (derived, still personal) | analyses.result |
| Chat transcripts with tool audit | Medium | chat_messages |
| Reference library (AADR-derived) | Public | data/derived |
- Another user / guest reads or alters someone else's data (IDOR, guessed ids).
- Network attacker intercepts traffic or forges tokens.
- Database / backup leak (stolen dump, misconfigured Supabase, leaked backup).
- Operator or log-pipeline exposure: genotypes end up in logs, traces or error reports.
- LLM provider exposure: genotypes sent to a third-party model.
- Malicious upload: zip/gzip bombs, path traversal, oversized bodies, binary payloads.
- Abuse / denial of service: floods of uploads, analyses or chat requests.
- Browser attacks on the API or rendered reports: XSS, clickjacking, CORS misuse.
- Every data endpoint needs a bearer token: either a Supabase access token or a Strata guest token.
- Supabase tokens: asymmetric RS256/ES256 tokens are verified against the project JWKS (cached
with a TTL). Legacy HS256 tokens are verified with
SUPABASE_JWT_SECRET. The accepted algorithm is pinned to the key type, so an HS256 token cannot be checked with a public key.alg=none, missingexp/sub, a wrong audience, a wrong issuer (whenSUPABASE_URLis set), anon-role keys and non-UUID subjects are all rejected. - Guest tokens: HS256 JWTs with issuer
strata-guest, a 24 h expiry (STRATA_GUEST_TTL_HOURS) and a randomjti. They are signed withSTRATA_GUEST_TOKEN_SECRET, which by default is derived from the KEK through HMAC. A valid signature is not enough: the guest profile must still exist server side and must not be expired. Once retention purges a workspace, its token is void. A guest token can never authenticate a non-guest profile, and the reverse also holds.
- Every query is scoped by
owner_id. A resource owned by someone else returns 404, the same response as a missing one, so ids cannot be probed (api/routers/*,_ownedhelpers). - Job handlers re-check ownership before decrypting a dataset (
jobs/handlers.py::_open_dataset). A forged job payload therefore cannot read another user's data. - Supabase row-level security is enabled on every user table, with
owner_id = auth.uid()(supabase/migrations/*_rls.sql). The API's service connection bypasses RLS and enforces the same rule in code. RLS protects direct PostgREST / supabase-js access. Clients get no access at all todataset_keys, and they cannot select theciphertext/noncecolumns of their own rows. Tests:tests/security/test_rls_postgres.py.
- Uploads are parsed in memory. Starlette's multipart spooling is raised above the upload limit, so files are never written to a temp file. The original file is discarded after parsing; only its SHA-256 is kept, for provenance.
- Raw DNA is harmonised to the reference SNP panel. Only the panel-aligned dosage vector is kept (int8, about 1 byte per panel SNP). Vendor metadata, indels, no-calls and off-panel SNPs are dropped. If no panel is installed, only biallelic called SNVs are kept (encrypted), so the dataset can be harmonised later. Re-uploading is the recommended path.
- The research agent sees derived data only: dataset metadata and coordinates (G25 rows or
PCA projections). It never sees dosages or calls (
api/routers/chat.py::_tool_context). Every tool call and tool result is persisted with the assistant message for audit.
- Envelope encryption. Each dataset gets a fresh 256-bit DEK. The payload is encrypted with
AES-256-GCM using a random 96-bit nonce. The associated data binds the ciphertext to its
dataset_idandowner_id, so swapping ciphertexts between rows fails authentication. - The DEK is wrapped (AES-256-GCM) with the KEK
STRATA_DATA_KEY. Wrapped keys are stored indataset_keys, a separate table from the ciphertext, and are tagged with a non-secret KEK id. To rotate: set a newSTRATA_DATA_KEY, move the old one toSTRATA_DATA_KEY_PREVIOUS, then re-wrap withKeyring.rewrap. - A SHA-256 of the plaintext is checked after decryption.
- Ciphertext blobs can live in the DB (
bytea), on local disk (0600 files) or in a private Supabase Storage bucket. That bucket has a restrictive policy that denies all anon/authenticated access.
DELETE /datasets/{id}crypto-shreds. It zeroes, then deletes, the wrapped DEK, then deletes the ciphertext blob and the row. Any copy of the ciphertext that survives in backups or object storage is then undecryptable. This holds as long as wrapped keys are not restored from an older backup, so keepdataset_keysbackups short-lived, or keep the KEK out of long-term backups.- Guest workspaces expire after 24 h. Every row a guest owns carries the same
expires_at. Purging runs in the worker loop every minute (security/retention.py), and with pg_cron every 15 minutes (purge_expired_data()insupabase/migrations/*_retention.sql). Keys are destroyed first. Finished jobs are deleted after 30 days.
- A root-logger filter refuses genotype-like payloads: rsID + call rows, VCF records, runs
of diploid calls and long dosage vectors. Such a record's message is replaced with
[redacted: genotype-like payload], and its args and traceback are dropped. - Request validation errors never echo submitted values. Unhandled errors return a generic 500 and log only the exception type and path. Job errors store only the exception message, never tracebacks or locals.
- The request body is limited (
STRATA_MAX_UPLOAD_MB, default 64 MB) before parsing. The limit checks bothContent-Lengthand streamed bytes. - There is an extension allow-list (
.txt .csv .tsv .vcf .gz .zip). Content is sniffed: no NUL bytes, valid UTF-8 or mostly-printable text. - gzip/zip input is decompressed with a 256 MB cap (bomb protection). A zip must hold exactly one file.
- Filenames are normalised (NFKC). Directories, control characters and unusual characters are stripped and the length is capped. The sanitised name is metadata only and is never used as a path.
- An in-process token bucket limits each client IP (all routes). A stricter bucket limits each
user on expensive routes: uploads, analyses and chat. Issuing guest tokens has its own
per-IP bucket.
STRATA_TRUST_PROXY_HEADERSmust be enabled only behind a trusted proxy (Cloudflare). For multi-instance deployments, also add edge rate-limiting rules. - Jobs have per-kind timeouts. A handler that overruns marks its job failed, and the worker exits (code 3) so the orchestrator restarts a clean process.
- Clients cannot point the R bridge at arbitrary server paths.
f2_dirandgeno_prefixare restricted to the configured reference cache.
- API responses carry
Content-Security-Policy: default-src 'none'; frame-ancestors 'none',X-Content-Type-Options: nosniff,X-Frame-Options: DENY,Referrer-Policy: no-referrer, HSTS, COOP/CORP and a restrictivePermissions-Policy. User-data responses are sent withCache-Control: no-store. - HTML reports are rendered with Jinja2 autoescaping and served as attachments with a CSP that allows only inline styles (no scripts).
- CORS is an explicit allow-list (
STRATA_CORS_ORIGINS) with credentials disabled. The bearer token is sent in a header, never in cookies, so the API has no CSRF surface.
- Generate the KEK with
openssl rand -base64 32and keep it in a secret manager. Losing it loses all datasets; leaking it together with a DB dump exposes them.STRATA_ENV=prodrefuses to start without it. - Use TLS everywhere (Cloudflare to the API, the API to Postgres with
sslmode=require). - The service-role key and the DB password are server-side only. The web app receives only the Supabase anon key.
- Never enable
STRATA_DB_ECHOin production (SQL parameters could include ciphertext).
- Rate limiting is per process. Run an edge limiter in front of horizontally scaled APIs.
- In-memory parsing means a ~30 MB raw file transiently occupies a few hundred MB of RAM, which bounds concurrency per instance.
- Python cannot reliably wipe secrets from memory. Plaintext exists in process memory while a request or job is running.
- Results (projections, weights) are personal data in their own right. They are not encrypted at the application layer; they rely on RLS, ownership checks and database encryption at rest.
Please report security issues privately to the maintainers (see README) rather than in public issues.