Spanlight threat model
This document describes what Spanlight protects, where its trust boundaries are, the threats at each boundary (classified with STRIDE), the controls that answer them, and the risks that remain. Every control names the module that implements it, so a reader can check the claim against the code. The companion OWASP ASVS Level 2 checklist gives a status for each verification requirement.
- Scope: the server (
backend/), the dashboard and its web server configuration (frontend/,deploy/Caddyfile), the Python SDK (sdks/python/) and the deployment files indeploy/. - Method: a data-flow view of the system, then STRIDE (Spoofing, Tampering, Repudiation, Information disclosure, Denial of service, Elevation of privilege) applied to every boundary a request or a credential crosses.
- Status: written against the code as of October 2026, before the first
1.0release. It is reviewed whenever a change adds a trust boundary, a credential type, an outbound integration or a store of user data.
To report a vulnerability, follow SECURITY.md.
1. System overview
Section titled “1. System overview”Spanlight ships as one image with three roles, plus Postgres and optional external services:
flowchart LR subgraph Clients B[Browser: dashboard SPA] S[Instrumented app: Python SDK or OTLP exporter] T[Script: personal access token or API key] end subgraph Deployment W[web: Caddy, static SPA, reverse proxy] A[api: FastAPI] K[worker: jobs] P[(Postgres 17: data, jobs, rate limits, outbox)] end O[(Object storage: exports, backups)] X[Third parties: GitHub, Google, email provider, Anthropic, Sentry, OTLP collector]
B -->|HTTPS, session cookie| W S -->|HTTPS, Bearer spl_live_| W T -->|HTTPS, Bearer spl_pat_ / spl_live_| W W -->|/api, /v1, /health| A A --> P K --> P A -->|presign| O K -->|write, delete| O A --> X K --> X- web (Caddy,
deploy/Caddyfile) serves the built SPA and proxies/api/*,/v1/*and/health/*to the api. It sets the security headers and the Content-Security-Policy. TLS is terminated in front of it (a platform edge or the operator’s reverse proxy); Caddy itself runs withauto_https off. - api (
backend/app/main.py) serves the dashboard API under/api/v1, ingestion under/v1/tracesand/v1/otlp/traces, and health probes. - worker (
backend/app/jobs/worker.py) runs retention, rollups, exports, email delivery from the outbox, online evaluation, experiment runs, backups and the demo traffic generator. - Postgres is the only datastore. The api and worker connect as a role without
SUPERUSERorBYPASSRLS; migrations run as the database owner. - Object storage (any S3-compatible service) holds trace exports and nightly database dumps. It is optional.
2. Assets
Section titled “2. Assets”| Asset | Where it lives | Why it matters |
|---|---|---|
| Prompts, completions and span attributes | spans and traces tables; export files; database dumps |
Customer data. Can contain personal data, business secrets and anything a user typed into an LLM application. |
Project API keys (spl_live_…) |
Only the SHA-256 of the secret, in api_keys |
Write telemetry into a project; with traces:read, read it. |
Personal access tokens (spl_pat_…) |
Only the SHA-256 of the secret, in personal_access_tokens |
Act as a user on /api/v1 within that user’s memberships. |
| Session tokens | Browser cookie; SHA-256 in sessions |
Full dashboard access as the user. |
| Passwords | argon2id hashes in users |
Account takeover, and reuse on other sites. |
| Two-factor seeds and recovery codes | Seeds sealed with AES-256-GCM in users; recovery codes as SHA-256 |
Second factor of every account that enabled it. |
| Email link tokens (verify, reset) and invite tokens | SHA-256 in email_tokens and invites; the raw link in a queued email until it is delivered |
Account recovery and organization membership. |
SECRET_KEY |
Environment of api and worker | Signs CSRF tokens, the OAuth state cookie and the two-factor login challenge. |
CREDENTIALS_KEYS |
Environment of api and worker | Opens every sealed two-factor seed, provider credential and alert channel secret. |
| Database credentials | Environment (DATABASE_URL, BACKUP_DATABASE_URL) |
BACKUP_DATABASE_URL uses a role that bypasses row-level security and can read every tenant. |
| Object storage keys | Environment (S3_ACCESS_KEY, S3_SECRET_KEY) |
Read and delete every export and every database dump. |
| Provider secrets | Environment (OAuth client secrets, email provider key, ANTHROPIC_API_KEY) |
Impersonating the instance to providers; spending the demo budget. |
| Alert channel secrets | Slack incoming-webhook URLs, PagerDuty routing keys and webhook signing secrets, sealed with AES-256-GCM in alert_channels |
Post to a team’s Slack, page an on-call rota, or forge signed alerts to a receiver that trusts the signature. |
| Audit log | audit_events |
Accountability for membership, key, project and account changes. |
| Availability | api, worker, Postgres | Ingestion must keep up with customers’ applications. |
3. Actors
Section titled “3. Actors”- Anonymous internet user: can reach the web port: the SPA, signup, login, password reset, the demo sign-in, the OAuth routes and the health probes.
- Signed-up user: sign-up is open, so anyone can hold a valid account and create their own organization. This is the main attacker for tenant isolation.
- Organization member with role
viewer,member,adminorowner(backend/app/core/permissions.py). A member may try to exceed the role. - Holder of a leaked credential: an API key in an application’s configuration, a personal access token in a script, an export download link, an invite link.
- Instrumented application: sends arbitrary, possibly hostile, telemetry with a valid API key.
- Operator: runs the deployment and holds every secret. Trusted. Mistakes in configuration are in scope for this document, malicious operators are not.
- Compromised dependency or third party: a provider, an email service, the CDN that serves the API reference page.
4. Trust boundaries and threats
Section titled “4. Trust boundaries and threats”Each table lists the threat, its STRIDE class, the control and the code that implements it.
TB1. Browser to web and api (dashboard)
Section titled “TB1. Browser to web and api (dashboard)”The browser holds a session cookie; every state-changing request crosses this boundary.
| Threat | STRIDE | Control | Code |
|---|---|---|---|
| Password guessing and credential stuffing | S | argon2id hashes; at most 5 failed sign-ins per email and 20 per IP in 15 minutes, counted in Postgres so the limit holds across replicas; two-factor failures count against the same limit | core/security.py (hash_password_async, verify_password_async), auth/login_throttle.py (enforce_login_throttle), api/v1/auth.py (login), api/v1/totp.py (totp_verify) |
| Learning which emails have accounts through login or password reset | I | An unknown email still verifies a dummy argon2 hash, so timing is equal; one error message for every failure; forgot-password answers 202 whether or not the address exists, and counts both alike | core/security.py (verify_password_async), api/v1/auth.py (login, forgot_password) |
| Session theft through script access or cross-site sending | S, I | Session cookie is HttpOnly, SameSite=Lax, and Secure whenever APP_BASE_URL is https; 32 random bytes; only its SHA-256 is stored |
api/v1/auth.py (set_auth_cookies), services/sessions.py, core/security.py (new_token, token_digest) |
| Session fixation and stale sessions | S | A new token is issued at every sign-in; sessions slide on a 7-day idle timeout with a 30-day absolute limit (1 hour for the demo); logout deletes the row; a password reset (by email or with the admin CLI) ends every session of the user | services/sessions.py, services/credentials.py (replace_password) |
| Cross-site request forgery | T, E | Two layers on every POST, PUT, PATCH and DELETE under /api: the Origin header must be in the allowlist (a missing Origin is refused), and the X-CSRF-Token header must equal the spl_csrf cookie and carry a valid HMAC bound to the session |
core/middleware.py (OriginCheckMiddleware), api/deps.py (authenticate_session), core/security.py (new_csrf_token, verify_csrf_token) |
| A bearer request falling back to the cookie, or skipping CSRF checks while acting anonymously | E | A request with Authorization: Bearer loses its Cookie header before routing and is authenticated by the bearer alone; routes that need no sign-in refuse a bearer instead of ignoring it |
core/middleware.py (BearerRequestMiddleware), api/deps.py (refuse_credentials, ANONYMOUS) |
| Login CSRF and account takeover through OAuth | S | Authorization code flow with PKCE (S256); state kept in a signed, 10-minute, HttpOnly cookie scoped to the OAuth routes and compared in constant time; a provider identity is matched by its stable subject, never by email; an email match links only when both the provider and the local account have verified the address; a link must finish in the session that started it |
auth/oauth_state.py, auth/oauth_client.py, auth/oauth_service.py (sign_in_with_profile, link_identity), api/v1/oauth.py |
| Pre-hijacking: registering a victim’s address before they do, then keeping access | S | An unverified local account never gets a provider identity attached by email match; a password reset that proves the inbox drops every identity linked while the address was unproven | auth/oauth_service.py (_resolve), auth/password_reset.py (complete_password_reset) |
| Open redirect after sign-in | S | The next path must start with exactly one /, contain no backslash after it and no control characters; it is checked again when the signed state is read |
auth/oauth_state.py (safe_next, read_state) |
| Bypassing the second factor | S, E | A password or provider sign-in for a user with TOTP returns a signed, 5-minute challenge instead of a session; the challenge is bound to a fingerprint of the current password hash, so a password change ends it; codes are accepted once (step tracking) within one step of clock drift; recovery codes are single use; organizations can require two-factor authentication for every member | auth/login_challenge.py, auth/totp.py (match_step), auth/totp_service.py, api/v1/totp.py, api/deps.py (_member_access) |
| Reset and verification link abuse | S, I | 32 random bytes, stored as SHA-256, spent by one conditional UPDATE, expiring after 1 hour (reset) or 24 hours (verify); the token travels in the URL fragment, which browsers do not send to servers or in Referer; a new reset link retires earlier ones; at most 3 reset requests per email and 10 per IP in 15 minutes |
auth/email_tokens.py, auth/password_reset.py, auth/verification.py |
| Cross-site scripting | T, E | React escapes all rendered values; no dangerouslySetInnerHTML or eval; a strict CSP on every response except the API reference page and the documentation site under /docs/, which have their own policies (the documentation site’s adds only its own inline scripts, by SHA-256 hash; the strict policy is default-src 'self', no script-src relaxation, frame-ancestors 'none', base-uri 'self', form-action 'self'); X-Content-Type-Options: nosniff. deploy/smoke.sh asserts the policy in CI |
deploy/Caddyfile, deploy/smoke.sh (check_content_security_policy) |
| Clickjacking | T | X-Frame-Options: DENY and frame-ancestors 'none' |
deploy/Caddyfile |
| Downgrade to plain HTTP | I | Strict-Transport-Security with includeSubDomains on every response; startup refuses the development SECRET_KEY when APP_BASE_URL is https |
deploy/Caddyfile, config.py (_require_real_secret_over_https) |
| Ending other people’s sessions, or reading their session list | E | Session routes act only on the caller’s sessions; the shared demo account cannot end sessions and sees only its own | api/v1/auth.py (list_sessions, delete_session) |
| Exhausting the server with unauthenticated requests | D | Login, signup (10 per IP per hour, counted before the password is hashed), password reset, verification mail and the demo sign-in are throttled. argon2id hashing and verification run on a dedicated two-thread pool with at most two in flight, so they neither block the event loop nor multiply memory use. See R5 for what remains. | api/v1/auth.py (signup), auth/login_throttle.py, core/throttle.py, core/ratelimit.py (DEMO_LIMITER), core/security.py (hash_password_async, verify_password_async: two-thread pool, two at a time) |
| Oversized request bodies on the dashboard API | D | Every /api/v1 body is limited to 1 MiB: a declared Content-Length over the limit is refused unread, and a chunked or understated body is cut off while it is read. Both answer 413 PAYLOAD_TOO_LARGE. Ingestion keeps its own larger limit (TB2). |
core/body_limit.py (BodyLimitMiddleware), main.py |
| Secrets shown once staying in caches | I | New API keys, invite links, new personal access tokens, two-factor setup (secret and otpauth URI) and recovery codes are sent with Cache-Control: no-store and Pragma: no-cache |
core/security.py (mark_uncacheable), api/v1/keys.py (create_key), api/v1/orgs.py (create_invite), api/v1/tokens.py, api/v1/totp.py |
TB2. Instrumented application to ingestion
Section titled “TB2. Instrumented application to ingestion”An application sends telemetry with a project API key to POST /v1/traces or POST /v1/otlp/traces. The content of the payload is untrusted.
| Threat | STRIDE | Control | Code |
|---|---|---|---|
| Writing into another tenant’s project | S, T | The project comes from the API key, never from the payload; the key needs the ingest:write scope; every write runs under row-level security bound to that project |
api/ingest.py (authenticate_ingest, _store), api/deps.py (require_key_scope), ingest/pipeline.py |
| Guessing or brute-forcing API keys | S | 32 base32 characters of secret (160 bits) behind a 12-character lookup id; only the SHA-256 is stored and compared in constant time; a wrong secret gets the same 401 whether the key exists, was revoked or expired | core/security.py (generate_key, parse_key, key_secret_matches), api/deps.py (_authenticate_api_key) |
| Leaked key used indefinitely | S | Keys can carry an expiry and can be revoked from the dashboard; revocation takes effect within API_KEY_CACHE_TTL_SECONDS (10 s by default) on each api process, which caches key rows that long; last use is recorded at minute resolution |
api/v1/keys.py (revoke_key), api/deps.py (_record_use), api/key_cache.py, revoke-key runbook |
| Oversized or compressed-bomb bodies | D | The body is read in a stream and refused above 5 MB; gzip and deflate are decompressed with the same cap on the output; at most 1000 spans per batch; per-key token bucket of 50 requests per second with bursts of 100 | api/ingest.py (read_bounded_body, _decompress, _check_span_count), core/ratelimit.py (INGEST_LIMITER) |
| Deeply nested JSON | D | JSON nested more than 64 levels (or deeper than the parser can follow) is refused with 400 INVALID_JSON instead of reaching recursive code; the same limit applies to JSON-encoded prompt and completion strings in OTLP attributes, which are kept as plain text when too deep; a pagination cursor that nests too deep is a 422; OTLP protobuf is bounded by the protobuf library’s own depth limit and fails as 400 INVALID_OTLP |
ingest/pipeline.py (parse_json_body), ingest/otlp.py (decode_protobuf) |
| Malformed values that break storage | T, D | Strict JSON (no NaN or Infinity), NUL characters stripped, typed schema with length and range limits, timestamps checked against clock skew and the retention horizon; a bad span is rejected alone, not the batch |
ingest/pipeline.py (parse_json_body), ingest/schemas.py, ingest/normalize.py |
| Secrets sent inside prompts or attributes being stored | I | Every string in input, output and attributes is scanned before storage: Spanlight credentials of every kind, OpenAI- and Anthropic-style keys, bearer tokens, AWS access keys and labelled AWS secrets, and Luhn-valid card numbers are replaced with [REDACTED]; payloads are truncated to 32 KB; a project can turn payload capture off |
core/redact.py, ingest/normalize.py (_prepare_payload) |
| Stored script in a payload attacking dashboard users | E | Payloads are rendered as text by React, never as HTML, under the strict CSP | frontend/src/features/traces/, deploy/Caddyfile |
| Replays inflating counts | T | Spans are upserted on their natural key and trace totals are recomputed from stored spans, so a replayed batch changes nothing | ingest/pipeline.py |
TB3. Scripts with personal access tokens or API keys to /api/v1
Section titled “TB3. Scripts with personal access tokens or API keys to /api/v1”| Threat | STRIDE | Control | Code |
|---|---|---|---|
| A token doing more than its user may | E | A token is its user: memberships are re-read on every request; a read token is refused on any permission classed write and on any method other than GET, HEAD or OPTIONS |
api/deps.py (require, _member_access), core/permissions.py (PERMISSION_CLASSES) |
| A leaked token turned into a session or more tokens | E | Session-only routes (sign-out, sessions, token management, two-factor settings, provider linking) refuse tokens with SESSION_REQUIRED and API keys with KEY_SCOPE |
api/deps.py (current_session), auth/tokens.py |
| An API key reading another project | E, I | A key may only use routes that opt in with allow_api_key=True, which is allowed only for project:read; it needs traces:read, and the project in the path must be its own (otherwise 404); the RLS binding comes from the key |
api/deps.py (_key_access) |
| Scraping a project through a read credential | D, I | Bearer reads are limited to 20 per second with bursts of 40 per credential | api/deps.py (_limit_bearer_read), core/ratelimit.py (API_READ_LIMITER) |
| A retried POST creating duplicates | T | Idempotency-Key support on exports: a replay is authorized again and answered with the stored response; keys are scoped to the principal |
core/idempotency.py, core/idempotency_store.py |
TB4. Application to Postgres: the tenant boundary
Section titled “TB4. Application to Postgres: the tenant boundary”All tenants share one database. A bug in any query must not show one tenant’s data to another.
| Threat | STRIDE | Control | Code |
|---|---|---|---|
| A query that forgets its project filter | I | Three layers: middleware identifies the principal; require() resolves the organization or project from the path and checks membership against the single permission table (non-members get 404); for project routes it binds the transaction with set_config('app.project_id', …, true). Row-level security is ENABLEd and FORCEd on traces, spans, both rollup tables and exports, so an unbound or wrongly bound transaction sees no rows |
api/deps.py (require), db/rls.py (bind_project), alembic/versions/0001_initial_schema.py, 0011_add_rollups.py, 0014_add_exports.py, ADR 0003 |
| The application role silently bypassing RLS | E | The app connects as a role created with NOSUPERUSER NOBYPASSRLS NOCREATEDB NOCREATEROLE; migrations run as the owner in a separate step whose URL never reaches the app process |
db/roles.py (ensure_app_role), deploy/compose.yaml, deploy/Dockerfile |
| Content search reading other tenants’ spans before the policy filters them | I | Content search (q on the trace list and exports) can only use its trigram index under forced RLS when the functions in the condition are LEAKPROOF, so Postgres may run them on other projects’ rows before the policy drops those rows. Migration 1007 marks two functions, only when it runs as a superuser: spanlight_search_text (slices and joins text; cannot fail on any stored value) and pg_catalog.texticlike, the function behind ILIKE. texticlike raises an error only for a pattern that ends in a lone escape character, and whether it raises one then depends on the row’s text, so a malformed pattern could leak one bit per row. The app never sends one: every pattern is % + the escaped text + % (escaped backslashes and wildcards). This is not an RLS bypass: no role or function skips the policy, and the policy still filters every row. explorer/content_search.py is the only ILIKE in the app; keep any new one on the same pattern rule. Without a superuser the marks are skipped and content search covers the window’s last hour only |
alembic/versions/1007_add_spans_content_search_index.py, explorer/content_search.py (text_search, content_start), db/roles.py (_mark_search_leakproof), tests/rls/test_content_search_tenancy.py |
| A binding leaking to the next user of a pooled connection | I | Both settings are transaction-local, so they end with the transaction | db/rls.py |
| Cross-project worker jobs | I | Only retention, rollups and export expiry set the bypass flag, each for its own transaction; the export job reads under the requesting project’s binding | db/rls.py (bypass_rls), jobs/tasks/retention.py, rollups/jobs.py, exports/expiry.py, exports/jobs.py |
| SQL injection | T, I, E | SQLAlchemy expressions and bound parameters everywhere, including the hand-written statements; DDL in the role provisioning uses psycopg.sql quoting |
core/ratelimit.py, jobs/tasks/retention.py, db/roles.py |
| Concurrent requests breaking an invariant | T | Row locks and conditional updates on the sensitive paths: sign-in against password reset, single-use tokens, invite acceptance, TOTP verification, provider unlinking, throttles (advisory lock per key), and deletion (fixed lock order) | services/credentials.py, auth/email_tokens.py (consume_token), api/v1/orgs.py (accept_invite), core/throttle.py, services/deletion.py |
| Losing track of who changed what | R | Membership, invite, project, key, organization settings and account security changes write an audit event with actor and IP in the same transaction; admins can read and export it as CSV | services/audit.py, api/v1/orgs.py (list_audit_events, export_audit_csv) |
TB5. Application to object storage (exports and backups)
Section titled “TB5. Application to object storage (exports and backups)”| Threat | STRIDE | Control | Code |
|---|---|---|---|
| Export of another tenant’s traces | I | Creating an export needs export:create on the project; the job reads under that project’s RLS binding |
api/v1/exports.py, exports/jobs.py |
| A download link that lives too long | I | Download URLs are presigned for one hour and only for done exports; files are deleted 7 days after completion, and the row is marked expired only once the object is gone |
exports/service.py (export_out, EXPORT_RETENTION), exports/expiry.py |
| CSV formula injection when an export is opened in a spreadsheet | T | Every CSV cell that starts with =, +, -, @, tab or carriage return is prefixed with '; the audit log CSV uses the same writer |
exports/writers.py (safe_csv_cell, csv_line), exports/audit.py |
| Database dump disclosure | I | Dumps are written to a private bucket; the dump credentials reach pg_dump only as libpq environment variables, never on a command line or in a log line, and the child process inherits nothing else from the environment |
backups/libpq.py, backups/service.py, backups/jobs.py, restore runbook |
| Unbounded backup growth | D | 14 daily and 8 weekly dumps are kept; anything else under backups/ that the job created is pruned |
backups/retention.py |
TB6. Application to third parties
Section titled “TB6. Application to third parties”| Threat | STRIDE | Control | Code |
|---|---|---|---|
| Server-side request forgery through user-supplied URLs | I, E | Two features connect to an address a user supplies: gateway credentials with a base URL, and alert channels (webhook URLs; Slack and PagerDuty are fixed to hooks.slack.com and the configured Events API URL). Every such connection goes through one vetting network backend that resolves the host, refuses private, loopback, link-local, multicast and other reserved addresses, and connects to the address it checked, so a DNS answer that changes between the check and the connection (rebinding) gets nothing. Webhook URLs must be https://, may not carry a user name or password, and are also resolved and checked when the channel is saved (422 UNSAFE_URL). Redirects are never followed and no proxy, cookie or .netrc is read. Other outbound calls go to fixed provider endpoints (GitHub, Google), to the operator’s email provider, object store and OTLP collector, and to Anthropic for the demo. An operator running Spanlight inside a private network can lift the webhook rules with WEBHOOK_ALLOW_PRIVATE_TARGETS |
core/egress.py, alerts/channels/http.py, alerts/channels/urls.py, gateway/credentials.py, auth/oauth_client.py, email/providers/, storage/s3.py, core/tracing.py |
| Channel secrets and outbound URLs leaking | I | Slack URLs, routing keys and webhook secrets are sealed with AES-256-GCM, never returned by the API (a webhook secret is shown once, on create and on rotation, with Cache-Control: no-store), and never written to a log, the audit log, last_error or an exception. httpx and httpcore log at WARNING and Sentry’s httpx integration is off, so a request URL cannot reach a log line or an error report. A failed delivery records the status and at most 400 characters of the start of the response, read from at most 1.6 KB of the body, so a hostile receiver can neither fill the database nor make the worker buffer a large answer |
alerts/channels/secrets.py, alerts/channels/http.py, core/logging.py, core/sentry.py, api/v1/alerts.py |
| Forged or replayed webhook deliveries | S, T | Each request carries an HMAC-SHA256 signature over <timestamp>.<body> under a per-channel secret, a timestamp and a stable delivery id, so a receiver can check origin, reject requests more than 5 minutes old and deduplicate retries |
notifications/signing.py, alerts/channels/webhook.py |
| Alert email used to mail strangers or to phish | S, D | Email channel recipients must be members of the organization with a verified email, checked when the channel is saved (422 RECIPIENT_NOT_MEMBER) and again when each message is queued, so a removed member stops receiving; the content is fixed templates with escaped values, and a channel test is limited to 10 an hour. ALERT_EMAIL_ANY_RECIPIENT lifts the membership rule for closed deployments only |
alerts/service.py (allowed_recipients), alerts/delivery_fanout.py, alerts/channels/email_render.py |
| Notifications lost or sent twice after a worker crash | T, D | Delivery is at least once by design: a worker claims an outbox row with a 60 second lease and a fencing token and settles it only while it still holds the token, so a stale worker cannot overwrite a newer one. Webhooks carry a stable X-Spanlight-Delivery and PagerDuty a dedup_key to deduplicate on; Slack and email can rarely repeat (R17) |
notifications/lease.py, notifications/jobs.py |
| Provider tokens leaking | I | The provider access token is used inside fetch_profile and dropped; provider error text is reduced to a short code before logging; redirects are not followed |
auth/oauth_client.py |
| Mail header injection | T | Messages are built with email.message.EmailMessage, which refuses line breaks in headers; HTML bodies escape every value; SMTP uses STARTTLS with certificate verification unless the operator turns it off |
email/providers/smtp.py, email/templates.py |
| Open mail relay through invitations | D | Mailed invites are limited to 20 per organization per hour; verification mail to 3 per user per hour | services/invites.py, api/v1/auth.py (request_email_verification) |
| Customer data reaching the error tracker | I | Sentry never receives request bodies or local variables; the query string, URL fragment and Referer header are removed from every event; default PII is off |
core/sentry.py (init_sentry, before_send) |
| Customer data reaching the tracing backend | I | Self-tracing records route templates, never bodies or headers; SQL spans carry the statement text with placeholders and never parameter values; request spans carry the request id and the kind of credential, never an id, email or secret; ingestion, health and metrics are never traced, so an instance can trace into itself without a feedback loop | core/tracing.py (EXCLUDED_URLS, TraceAttributesMiddleware) |
| Email bodies with live links kept in the database | I | The outbox payload is reduced to its subject and a short summary once the delivery settles. The one exception is a failed alert delivery, whose payload is kept so it can be retried; it holds the alert text and no secret, because channel secrets live on the channel and are read at send time | notifications/jobs.py (reduced_payload) |
| Demo spending running away | D | The demo generator makes real provider calls only with ANTHROPIC_API_KEY set, one attempt per job, with a month-to-date budget check before each call |
jobs/tasks/demo_traffic.py |
| Spending past a budget, or dodging the guard | E, D | A blocking budget is enforced in the gateway on the evaluated state of its alert rule: one indexed lookup per call, under the project’s row-level security binding, before any call to a provider. A model budget matches the model the client asked for and the model each route target would send after aliasing, and a snapshot suffix (-YYYYMMDD, -YYYY-MM-DD, -latest) counts as the same model, using the rule the price table uses, so naming a dated snapshot does not escape the budget. A state left over from an earlier period stops blocking at the rollover. If the check itself cannot run, the call fails and is not let through. Ingestion is never blocked, so a budget cannot be used to hide traffic. Blocking lags spend by up to one evaluation (R18) |
budgets/guard.py, core/model_match.py, gateway/runtime.py, alerts/evaluation.py |
TB7. Operator, deployment and operational endpoints
Section titled “TB7. Operator, deployment and operational endpoints”| Threat | STRIDE | Control | Code |
|---|---|---|---|
| Secrets in logs or errors | I | Settings errors never echo input (hide_input_in_errors, keyring errors name the entry, not the value); access logs record the path without the query string; tokens and keys are logged by id, never by value |
config.py, core/crypto.py (parse_keyring), core/middleware.py (RequestContextMiddleware), api/v1/tokens.py |
| Stolen database revealing second factors | I | TOTP seeds are sealed with AES-256-GCM under a keyring in CREDENTIALS_KEYS that never enters the database; the key id is bound as associated data; rotation keeps old keys readable until nothing is sealed under them |
core/crypto.py, auth/totp_service.py, ADR 0009, rotate-secrets runbook |
| Metrics disclosure | I | /metrics is not proxied by Caddy; it answers 404 unless METRICS_TOKEN is set and then needs that bearer token, compared as bytes in constant time (a non-ASCII header is a 401, not an error) |
api/health.py (metrics), deploy/Caddyfile |
| Readiness probe disclosure | I | /health/ready is public by design (uptime monitors use it). It returns status words and three numbers: the worker heartbeat age, the outbox backlog (capped at 10 000) and the alert evaluation lag. No hostnames, versions or error text |
api/health.py (ready) |
| Spoofed client addresses defeating per-IP limits | S | Caddy trusts no client-IP header unless TRUSTED_PROXIES names the proxy in front of it, and overwrites X-Forwarded-For with the address it decided on; the api is not published outside the private network |
deploy/Caddyfile, deploy/compose.yaml |
| Version and server fingerprinting | I | The Server header is removed; Caddy’s admin API is off |
deploy/Caddyfile |
| Running as root inside the container | E | The backend image runs as an unprivileged app user and ships without pip; the web image runs Caddy as an unprivileged user (uid 10001) on port 8080 with no file capabilities |
deploy/Dockerfile |
| Tampered or vulnerable release images | T | Release images are scanned with Trivy (fail on fixable HIGH and CRITICAL), signed with cosign keyless signing, and published with signed CycloneDX SBOMs; CodeQL and Dependabot run on the repository | .github/workflows/release.yml, .github/workflows/codeql.yml, .github/dependabot.yml |
TB8. Prompts, datasets and evaluation
Section titled “TB8. Prompts, datasets and evaluation”Prompts, scores, datasets, review queues, evaluators and experiments store customer content and, in the case of judges, experiments and the playground, spend the organization’s provider credentials. They sit behind the same tenant boundary as traces (TB4), plus the controls below.
| Threat | STRIDE | Control | Code |
|---|---|---|---|
| A judged answer steers its own score (prompt injection) | T, E | The judged input, output and expected output are placed between XML-style markers, the system message says everything between them is data to evaluate and never instructions, and the verdict must be one JSON object whose score is clamped to 0..1. This reduces injection and does not remove it, so scores are labelled advisory in the documentation and the evaluator form, and nothing in Spanlight acts on a score automatically. A judge’s own calls are never sampled by any evaluator, so a steered answer cannot start a loop (R19) | evals/judge_templates.py, evals/verdict.py, evals/sampling.py (is_excluded) |
| A webhook evaluator used to reach internal services (SSRF) | I, E | A webhook evaluator posts only to the URL of a webhook alert channel of the organization, through the same vetted client as alert webhooks: https:// only, private, loopback, link-local and reserved addresses refused on every connection (rebinding gets nothing), redirects never followed, no proxy read, so WEBHOOK_ALLOW_PRIVATE_TARGETS is the only way to lift it. The reply is read through a 64 KB bound and the request body is capped at 256 KB, one attempt with a 30 second limit for the whole exchange. A failure is a short code (webhook_unreachable, webhook_status_<code>, webhook_invalid_reply), never a URL or response text |
evals/evaluators/webhook.py, evals/runner_build.py, alerts/channels/http.py, core/egress.py, core/egress_http.py |
| A forged or replayed evaluator request | S, T | Every request is signed with the channel’s secret over <timestamp>.<body> (X-Spanlight-Signature), the same scheme as alert webhooks, so a receiver can check origin and reject a timestamp more than 5 minutes old. There is no retry, so a request is sent once |
notifications/signing.py, evals/evaluators/webhook.py |
| Unattended judge spend (runaway cost) | D, E | Only admins create evaluators. A judge cannot be saved without a positive monthly budget and a priced model. Before every call the run reserves a ceiling estimate of its cost under an advisory lock per evaluator, so concurrent runs cannot both pass the limit and a run that does not fit sends no request. A call that may have been billed is charged its usage or its whole estimate, and a run stuck running for 10 minutes is charged its reservation. Tests and experiment use draw on the same budget. Project block budgets still apply to the call, and the sampler caps runs per evaluator per cycle (EVAL_SAMPLER_MAX_RUNS_PER_CYCLE) |
evals/budget.py, evals/reservation.py, evals/runner_judge.py, evals/validation.py, evals/jobs.py |
| Experiments and the playground spending without a limit | D | An experiment run has a cost limit that counts the estimates of the calls in flight as well as what was spent, one run at a time per experiment, and a worker reaper stops a run whose worker died. The playground bounds each call by max_tokens (at most 4096), allows 30 runs a minute per user and obeys the project’s blocking budgets. A member can still spend the organization’s credentials this way (R20) |
evals/experiment_run.py, evals/experiments.py, playground/service.py, playground/schemas.py |
| Hostile or oversize dataset upload | D, T | The upload route has its own body limit of 5 MB plus framing, enforced while the body streams; the file is read in at most 5 MB, must be UTF-8, is parsed in a thread pool so it cannot stall the event loop, and parsing stops once a dataset would be full (10 000 items). Each value is at most 64 KB and metadata 8 KB as compact JSON, NUL is removed, a malformed CSV record ends the file instead of being resumed mid-field, at most 100 problems are reported, and a reported reason never repeats the content of a line | core/body_limit.py, api/v1/dataset_items.py, datasets/importers.py, datasets/schemas.py |
| A regular-expression rule that never finishes (ReDoS) | D | The regex rule runs on the regex package with a 1 second timeout, on the first 10 000 characters of the text, in a worker thread that releases the interpreter lock while matching, so a pattern that backtracks without end fails its run with regex_timeout and leaves nothing spinning. A pattern is at most 500 characters and must compile when the evaluator is saved |
evals/evaluators/heuristic.py (rule_regex, RegexRule) |
| A key that can send traces also reading prompts, or reading another project’s prompts | E, I | Reading a prompt needs the prompts:read scope, which is separate from ingest:write; keys made before it existed do not have it (403 KEY_SCOPE) and a new key must ask for it. The lookup runs under the key’s project binding, so a name that exists only in another project is 404. Responses are private and the route shares the per-key rate limit with ingestion |
api/v1/prompts_read.py, core/scopes.py, api/deps.py (require_key_scope) |
| Rewriting history behind a trace, or reading through a child table | T, I | Prompt versions cannot be changed after they are written (a database trigger rejects UPDATE), so the version a trace links to is what was sent. Tables without a project_id (versions, labels, dataset items, queue items, evaluator runs, experiment runs and items) have row-level security through their parent. Scores are keyed by (project_id, id), so a client-chosen id cannot collide with another tenant’s |
alembic/versions/0500_add_prompts.py, 0502_add_scores.py to 0506_add_experiments.py, scores/service.py |
5. Residual risks
Section titled “5. Residual risks”These are known and accepted for now, or waiting for a fix. Each names what an operator can do about it.
| # | Risk | Why it remains | What reduces it |
|---|---|---|---|
| R1 | Invite tokens travel in the URL path (/invite/<token>) and in the query of GET /api/v1/invites/preview?token=…. They can end up in browser history, in the logs of proxies in front of Spanlight, and in the Referer of requests the invite page makes. An invite is accepted by any signed-in holder of the link, whatever address it was mailed to. |
Invites are meant to be shareable by hand. The token is 32 random bytes, stored only as SHA-256, single use and valid for 7 days. Spanlight’s own access log omits query strings, Sentry events drop Referer and the query, and Referrer-Policy: strict-origin-when-cross-origin keeps the path from other sites. |
Revoke unused invites; do not log full URLs in proxies in front of Spanlight. Moving the token into the URL fragment, as reset and verification links already do, would remove most of this risk. |
| R2 | Database dumps contain every prompt and completion, and are protected by the bucket only. Dumps are not encrypted by Spanlight. The api and worker hold the same object storage keys for exports and backups, so either process, if compromised, can read or delete every dump. BACKUP_DATABASE_URL must use a role that bypasses row-level security; the Compose file and the Railway template give it to the worker only. |
Client-side encryption of dumps and separate backup credentials are not built yet. | Use a private bucket with provider-side encryption and versioning or object lock; give the storage keys the narrowest policy the service allows; keep BACKUP_DATABASE_URL out of the api’s environment (the Compose file and the Railway template already do); see the restore runbook. |
| R3 | Account enumeration through signup. POST /api/v1/auth/signup answers 409 EMAIL_TAKEN for a registered address. Login and password reset do not reveal it. |
A signup form that hides existing accounts needs an email round trip before the account exists, which instances without email cannot do. | Signup is limited to 10 attempts per IP per hour, which slows bulk enumeration but does not stop it. |
| R4 | Organization deletion is synchronous. Deleting an organization removes its projects, traces and spans in one transaction during the request. A very large organization holds locks for a long time and can time out. | Acceptable at the sizes self-hosted instances run today; a very large organization needs a background deletion job, which does not exist yet. Only an owner can delete, by typing the slug. | Lower project retention before deleting a very large organization. |
| R5 | Unauthenticated work is bounded, not free. Password hashing (argon2id, 64 MiB, about 30 ms) runs on a dedicated pool of two threads, and at most two hashes run at once however many requests ask, so hashing uses about 128 MiB at most and never blocks the event loop; further sign-ins and signups wait in line (each waiting request is a cheap coroutine, so a sustained flood shows up as slow logins, not as memory growth). Signup is limited to 10 per IP per hour, login per email and per IP (5 and 20 per 15 minutes), and /api/v1 bodies to 1 MiB. Many source addresses can still queue hashes faster than two threads drain them (about 70 per second), which slows legitimate sign-ins while the flood lasts. |
A small fixed bound protects the api’s memory and the rest of its traffic, which matters more than login latency during an attack. The per-IP limits only slow a single source; they are only as good as the client address the proxy reports (R13). | Put a request rate limit for /api/* on the reverse proxy in front of Spanlight. |
| R6 | Rate limits fail open. When the rate-limit pool cannot provide a connection within a quarter of a second, or the database is unreachable, the request is let through, spanlight_rate_limit_unavailable_total is incremented and a warning is logged. A burst of concurrent requests can exhaust the 5-connection pool and so skip limits while it lasts. |
A limiter outage must not become an ingestion outage (ADR 0007). | Alert on spanlight_rate_limit_unavailable_total (or the rate_limit_unavailable log line); size RATE_LIMIT_POOL_SIZE for your concurrency. |
| R7 | Row-level security guards against missing filters, not against SQL injection. The bypass flag is a session setting the application role can set, so code that could run arbitrary SQL as that role could read every tenant. Tables other than the telemetry, rollup and export tables (memberships, keys, invites, audit events) are isolated by the application’s checks only. | Postgres cannot restrict who sets a custom setting. All SQL is parameterized. | Keep the application role exactly as provisioned; do not grant it more. |
| R8 | Telemetry is stored in clear text. Prompts and completions are readable by anyone with database or dump access. Redaction is pattern-based and catches known credential shapes and card numbers, not arbitrary personal data. | Application-level encryption of telemetry would remove search and aggregation. | Encrypt the database volume; turn off payload capture for projects that must not store content; set short retention. |
| R9 | Long-lived sessions. Sessions last up to 7 days idle and 30 days in total, longer than the ASVS Level 2 guidance of 12 hours or 30 minutes idle. | A product decision for a developer tool. Users can end other sessions, and a password reset ends all of them. | None in configuration yet. |
| R10 | Second factor gaps. No WebAuthn; recovery codes are 50 random bits stored as unsalted SHA-256 (single use, and online guesses are throttled); users are not notified by email when a password, a second factor or a token changes. Creating API keys and tokens and turning two-factor authentication on do not ask for the password again. | Not built yet. | Organizations can require two-factor authentication for every member. |
| R11 | The API reference page has a relaxed policy. /api/docs allows inline scripts and loads Swagger UI from jsDelivr without Subresource Integrity, so it trusts that CDN. The page is public, needs no sign-in to load and is on the same origin as the dashboard. |
FastAPI’s Swagger page is built that way. The relaxed policy is limited to that one path, which deploy/smoke.sh asserts. |
Block /api/docs at the proxy if you do not need it. |
| R12 | style-src 'unsafe-inline' in the strict policy. The dashboard’s components and charts set inline style attributes, which need it. Scripts are not affected: the strict policy has no script-src relaxation. |
Removing it needs the components to stop using inline styles. | None. |
| R13 | Client addresses depend on the proxy chain. The api accepts X-Forwarded-For from any peer (--forwarded-allow-ips='*') and relies on only Caddy being able to reach it. If the api port is exposed directly, per-IP login and demo limits can be bypassed with a forged header. |
Simplifies deployment behind platforms whose proxy addresses change. | Never publish the api port; set TRUSTED_PROXIES to the proxy in front of Caddy only. |
| R14 | Traffic inside the deployment is not encrypted by default. Caddy to api and api to Postgres use plain connections on the private network, and TLS for clients is terminated in front of Caddy. | The deployment targets a single private network. | Use sslmode=verify-full in the database URLs on networks you do not control; terminate TLS on a proxy you trust. |
| R15 | Deleted data persists in backups for up to 8 weeks (14 daily and 8 weekly dumps), and an email link stays in the outbox until its delivery settles. | Backups exist to bring data back. | Account for backup retention in your data deletion policy. |
| R16 | No breached-password check and a 10-character minimum. Below the ASVS Level 2 requirement of 12 characters. There is no in-app password change for a signed-in user; changing a password goes through the emailed reset or the admin CLI. | Not built yet. | Use a password manager; enable two-factor authentication. |
| R17 | Alert and notification delivery is at least once. After a worker crash between a provider accepting a message and Spanlight recording it, the message is sent again once a 60 second lease expires. Webhook receivers can deduplicate on X-Spanlight-Delivery and PagerDuty on its dedup key; Slack and email have no such key. |
A key cannot be added to those channels. | Deduplicate in the receiver where you can; a repeated Slack or email message is harmless but visible. |
| R18 | A blocking budget is a guard rail, not a hard cap. The gateway reads the state written by the once-a-minute evaluation, so calls made after the amount is crossed and before the next pass still go through, and a stopped worker leaves the last state in force. A webhook URL is also readable by every member of the organization, query string included. | Summing spend on every call would put an aggregate on the hot path of every request; the URL is not a secret by design. | Set the budget below the amount that must never be exceeded; watch alert_evaluation_lag_s; authenticate webhooks with the signature, not a token in the URL. |
| R19 | A judge’s score can be steered, and the judged text leaves your deployment. The text a judge reads is untrusted, and a model can be talked into a higher score by text inside the answer it judges; a judge can also simply be wrong. The input, output and expected output of each judged trace are sent, as stored (already redacted), to the provider of the credential the evaluator uses. | Marking the judged text as data lowers the chance of steering but cannot remove it, and a judge needs the content to judge it. | Treat scores as a signal, not proof, and do not let one decide anything security-sensitive. Choose a provider you already trust with that content, turn payload capture off for projects that must not send it (a span without a payload is not judged), and filter evaluators to the environments you want scored. |
| R20 | Any member can spend the organization’s provider credentials through the playground and experiments. The playground and experiment runs need only the experiments:run permission and may use any credential of the organization, which only owners manage. |
The point of the playground is that people can try models without holding keys. Each call is bounded by max_tokens, the playground allows 30 runs a minute per user, a run has a cost limit, and project blocking budgets apply. |
Set blocking budgets on projects that share credentials, keep a separate credential (with a provider-side spend limit) for experiments, and watch the project’s spend. |
6. Assumptions
Section titled “6. Assumptions”- The operator keeps
SECRET_KEY,CREDENTIALS_KEYS, the database passwords and the storage keys secret, and setsAPP_BASE_URLto the https address users reach. - TLS is terminated by a trusted proxy or platform edge in front of Caddy, and only that proxy can reach Caddy’s trusted client-IP header.
- The api and Postgres are reachable only on a private network.
- The object storage bucket is private.
- Providers (GitHub, Google, the email provider, Anthropic, Sentry) behave as documented. A compromise of one of them is out of scope, but Spanlight sends them no more data than listed above.
This page is generated from docs/security/threat-model.md in the repository.