What TeamCraft protects, from whom, where the trust boundaries are, and – per
threat – what the code does about it and which test pins it. Every claim
names the code (file, function) and the test; a threat without both is
marked open and has an entry in docs/OPEN_WORK.md (section 2, Security). Written against TeamCraft 0.2.0 (main
after #108); keep it in
step with the code – a change to a mitigation changes its row.
Status words: mitigated (code and test), partial (code, but a gap
named in the row), open (no mitigation), accepted (a deliberate
decision, documented). STRIDE letters: Spoofing, Tampering, Repudiation, Information disclosure, D
enial of service, Elevation of privilege.
B1 – network ↔ server. Everything from browsers, the desktop app, MCP
and REST clients is untrusted until authenticated. TLS ends at the reverse
proxy (the server speaks HTTP); deployments without TLS are out of scope (SECURITY.md).
B2 – authenticated user ↔ data of other project groups. A signed-in
user is trusted with their role, not with restricted groups they are not
a member of.
B3 – file bytes ↔ server. Uploaded files are hostile input to the
parsers (CAD, mesh, point cloud, BOM tables, PDF, simulation results).
B4 – text in the database ↔ the LLM. File text, descriptions and
comments reach the model provider and can carry instructions.
B5 – server ↔ outbound targets. The server makes requests to URLs
partly chosen by administrators (webhooks, LLM, IdP).
B6 – server ↔ host. Administrators, the file system, the database and
backups are trusted; attacks that need them are out of scope (SECURITY.md).
Argon2id with a random salt (src/auth.rshash_password); minimum 10 characters (src/domain/validation.rsvalidate_password)
src/auth.rspassword_hash_round_trip
mitigated
User enumeration at sign-in
I
one message for unknown user, wrong password, OIDC-only and inactive accounts; a dummy Argon2 verify for unknown users (src/services/users.rsauthenticate_local, src/auth.rsDUMMY_HASH)
tests/web_ui.rswrong_password_is_rejected (message); no timing test
mitigated
Password guessing (A1)
S
failed sign-ins counted per username and per client address for 15 minutes (TEAMCRAFT_SIGNIN_MAX_FAILURES, 10 per name, 50 per address); then 429 with Retry-After without checking the password; the address is the peer or, behind an internal proxy, the proxy’s X-Forwarded-For entry; in memory per process (src/auth.rsSignInThrottle, client_address; src/web/auth_pages.rslogin_submit). The own password change (web form /account/password and POST /api/v1/me/password of the desktop app) shares the per-username counter: a wrong current password counts, a full counter refuses even the right one with 429 (src/services/users.rschange_own_password); so whoever holds a stolen session or token can lock the user’s web sign-in for 15 minutes, as anyone can at the sign-in form. Not throttled: the desktop token exchange (its one-time code is deleted on the first attempt)
256-bit random tokens, only SHA-256 hashes stored (src/auth.rsgenerate_token, hash_token; src/services/users.rscreate_session)
src/auth.rstokens_are_random_and_hashed
mitigated
Session cookie read by script or sent cross-site
I
HttpOnly; SameSite=Lax, Secure when the public URL is https (src/auth.rscookie_attributes, src/config.rsTEAMCRAFT_COOKIE_SECURE)
– (attributes not asserted by a test)
partial: no test
Session outlives its purpose
S
absolute lifetime TEAMCRAFT_SESSION_TTL_HOURS (72 h); the lookup requires expires_at > now() and an active user (actor_for_session); sign-out deletes the row; deactivation and an admin password reset delete all sessions
A stolen session survives the victim’s password change
S
fixed in #110: users::change_own_password ends every other session of the user (the one that made the change stays) and records how many in the audit entry
256-bit random, SHA-256 hash stored, shown once, optional expiry, revocation by the owner or an admin (audited token.revoke), dead with a deactivated account (src/services/users.rscreate_token, revoke_token, actor_for_api_token)
token scopes (domain::TokenScope, enforced in the ApiCaller extractor and the MCP dispatcher): full = read, write, approve (existing and desktop tokens), write = no review, change request decision or obsoleting, read_only, mcp_only; a token cannot create a wider one; the desktop token expires after 30 days without use (sliding)
tests/token_scopes.rs; unit tests in src/domain/tokens.rs
mitigated (F2 fixed; the desktop token is still full)
Sign-in events cannot be traced
R
token creation and revocation, password changes, role changes, OIDC provisioning and desktop authorisation are audited, and so are sign-in (user.sign_in with the method: password, SSO, setup), failed password sign-in (user.sign_in_failed; an unknown typed username is not kept), a wrong current password at the own password change (user.password_failed) and sign-out (user.sign_out) – never a password or token (src/services/users.rscreate_session, delete_session, record_failed_sign_in)
csrf_guard on every non-GET web request compares Origin (or Referer) with the host and the public URL; Origin: null is refused; SameSite=Lax as second layer (src/web/mod.rscsrf_guard, same_origin). A request without both headers is refused (#111); X-Forwarded-Host is trusted as sent by the proxy (documented in the install guide)
Strict-Transport-Security: max-age=31536000 on every response when the public URL is https (follows TEAMCRAFT_COOKIE_SECURE, src/lib.rs; #111); the install guide covers the proxy
reads go through the SQL guards of src/services/access.rs (item_visible, project_visible, change_visible, request_visible, …) with the actor’s hidden_projects; a hidden object answers like a missing one (ensure_visible); design in ADR 0001
tests/read_permissions.rsoutsiders_see_nothing_of_a_restricted_group and every_route_has_a_placeholder_rule (every web, REST and MCP route), texts_naming_restricted_objects_are_redacted, the review-round tests; tests/no_unchecked_in_handlers.rs
mitigated; residual oracles listed in SECURITY.md
An administrator who is not on the named list reads a restricted group (#151)
I
need-to-know policy per group: with_reason (time-limited, audited elevation with a written reason, managers notified) or none; the rule fills Actor::hidden_projects for administrators (access::hidden_of_user), so all guards apply
tests/need_to_know.rs
mitigated for groups with a policy: conditions on membership – IdP claims, allowed networks, maximum sign-in age, administrators included (step 2, services::membership_conditions, tests/membership_conditions.rs); interface switches (api_tokens, mcp, assistant, webhooks, packages, notification_content, external_llm, desktop_sync, tests/interface_switches.rs, tests/interface_switches_remaining.rs); the read and download log incl. MCP and assistant tool reads and desktop Get (tests/access_log.rs, tests/access_log_channels.rs); the access review with schedule and reminders (tests/access_review.rs, tests/access_review_schedule.rs); token scopes (F2)
A viewer or editor writes beyond the role
E
Actor::require, can_edit_project, revisions::ensure_editable in the services (src/auth.rs, src/services/*)
One request makes the server resolve an unbounded list (A2)
D
list lengths are checked before the list is resolved: the affected items of a change request at most requests::MAX_ITEMS (100) in the REST API, the MCP tool and again in requests::create (distinct items; repeated references are dropped before any lookup – before the CodeQL triage each repeat was looked up and inserted, ~150 000 times for a 2 MB body of one item); simulation results at most MAX_RESULTS (2000) per import; JSON bodies are capped by axum’s default limit (2 MB)
iss, aud, exp (120 s skew), nonce and sub are checked (validate_id_token_claims); the issuer of the discovery document must match the configured one. The signature is not verified: the token comes straight from the token endpoint over the back channel, which OpenID Connect Core 1.0, 3.1.3.7 step 6 allows only when that channel is TLS. TEAMCRAFT_OIDC_ISSUER_URL also accepts http:// (src/config.rs), and then nothing protects the token against A5
without TEAMCRAFT_OIDC_VIEWER_GROUP every account of the provider signs in as viewer; with it, only members (src/oidc.rsrole_for_groups; documented in docs/src/admin/users.md)
src/oidc/tests.rsgroup_mapping
accepted (documented)
Removed at the IdP, still signed in
E
the role follows the groups at the next SSO sign-in. Back-channel logout (OpenID Connect Back-Channel Logout 1.0, POST /auth/oidc/backchannel-logout): a logout token signed by the provider ends every browser session of the user at once (src/oidc.rsverify_logout_token, src/services/users.rsidp_logout, audited user.idp_logout). Without it a running session stays until it expires (72 h by default). API and desktop tokens of SSO users can get an idle lifetime (TEAMCRAFT_OIDC_TOKEN_IDLE_DAYS), and administrators see SSO users without sign-in or token use for 90 days who still hold something (services::offboarding::idle_sso_users, #173 step 9). A need-to-know group can set a maximum sign-in age (max_signin_age_hours): after it, the group is hidden from that session (and from tokens once the user’s last sign-in is that old) until the user signs in again, with the claims the IdP gives then (services::membership_conditions)
mitigated when the provider sends back-channel logout (F7); otherwise until the session expires
Forged back-channel logout ends sessions
S, D
the endpoint is outside the cross-site guard and takes no cookie; the logout token is the credential: a JWS signed with a key of the provider’s jwks_uri (RS256 or ES256 only, alg: none and a key whose alg differs are refused), iss, aud, iat (at most 5 minutes old), exp, the logout event, a sub and no nonce are checked; an unknown key id fetches the keys again at most once a minute; a refused token gets 400, an unknown subject 200 without effect. No jti replay cache: a replayed token only ends sessions again
object paths only from 64 hex characters (src/vault.rsobject_path); staging names are UUIDs; file names are sanitised (util::sanitize_filename: no /, \, control characters or leading dots)
the hash is computed while storing, chunked uploads are re-hashed against the announced size and SHA-256 (src/vault.rsUpload::write, hash_staged; src/services/uploads.rsverify_content)
downloads stream the blob without re-hashing; teamcraft vault check --verify-hashes and every backup find it (Vault::verify, backup::copy_checked_objects)
sessions are loaded only for their owner (others get 404), expire after 24 h, appends are serialised and need the confirmed offset (409 otherwise) (src/services/uploads.rsload, append)
TEAMCRAFT_MAX_UPLOAD_MB (512) as body limit and in Upload::write; chunks up to TEAMCRAFT_UPLOAD_CHUNK_MB (16); at most 16 open upload sessions per user; expired sessions and stale staging files are removed; optional storage quotas per user and for the vault (413)
downloads are always Content-Disposition: attachment (util::content_disposition), X-Content-Type-Options: nosniff and the CSP on every response; request attachments additionally default-src 'none'; sandbox. The stored Content-Type is the one the client sent (files::clean_content_type checks only its form)
– tests/web_security.rs pins attachment, nosniff and CSP
A crafted mesh, STEP, PDF or result file crashes or hangs the server (A3)
D
3D conversion (teamcraft preview-mesh) and the text of PDF and HTML reports (teamcraft text-extract --format pdf or html) run in a child process with a wall-time limit, kill_on_drop, size caps per format and two at a time (src/preview.rsrun_worker, src/pdftext.rs; HTML: the first 16 MB are read, at most 256 open elements are tracked, 200 000 characters out, src/htmltext.rs); a failure is cached, not retried
RLIMIT_AS 1 GiB on Unix (src/preview.rslimit_memory); no memory limit on Windows; a 3MF package entry may claim up to 2 GiB (src/mesh.rszip_entry)
–
partial (F11)
A compromised child reads the server’s secrets
I
the children start with a cleared environment (env_clear in preview.rs, pdftext.rs); no sandbox beyond the process boundary
–
mitigated (F12)
Parsers that run inside the server process
D
CAD reference scan (src/cadrefs.rs, with nxpart, swpart), plain text index, thumbnails, BOM import (stepbom, bomtable) run on blocking threads with their own caps: scan 32 MiB, text 4 MiB / 200 000 characters plus a 64 KiB tail of a larger plain-text file (at most a quarter of the characters, only after a text head, no NUL), result-template scan 2 MB head plus 2 MB tail of every scannable upload above 2 MB (up to 4 MB held per file during a check-in, also for files no template reads), thumbnail stream 8 MiB, tables 20 MiB, STEP 200 MiB, CATIA product structure 64 MiB (src/catprod.rs, assembly previews), Creo assembly structure 64 MiB (src/mesh/creo/asm.rs, section sizes bounded by the container reader), Deflate sections bounded and CRC-checked (src/swpart.rs). A panic ends only the blocking task. XLSX and ODS are unpacked by calamine in the server process without a cap on the unpacked size (src/bomtable.rs), so a ZIP bomb under 20 MiB can exhaust memory
catch_unwind around third-party readers (truck STEP, glTF, pdf_oxide); fifteen cargo fuzz targets (creopart included) run weekly (fuzz/, .github/workflows/fuzz.yml, Fuzzing) – the workflow built no target until fix/fuzz-build-target (it defaulted to the musl triple); the seed and fixture corpus runs in every CI run. #![deny(clippy::unwrap_used, expect_used, indexing_slicing, panic, unreachable)] on the readers of untrusted files (mesh, simmesh, swpart, nxpart, invpart, invplace, stepbom, bomtable, cadrefs, textindex, htmltext, thumbnail, resultscan, cadformat; #106, #108), which fixed a 3DS and a reference-scan panic. Not fuzzed: simmesh, pdftext; pdftext is not under the lint
A webhook reaches internal services or cloud metadata (SSRF)
I, E
only administrators manage webhooks (src/services/webhooks.rs, Actor::require(Role::Admin)); http/https without credentials; IP literals checked when saved and again when sent (TargetPolicy::check_url); every resolved address checked when the connection is made (GuardedResolver), loopback, private, link-local, shared, unique-local, NAT64/6to4 forms refused unless the host is listed; no redirects, no proxy (src/webhooks.rs)
POST /api/v1/mcp and /api/v1/assistant take bearer PATs only; the web sidebar uses the session with the CSRF guard (src/mcp/mod.rs, src/api/mod.rs)
tests/mcp.rsmcp_protocol_and_tools (401 without token)
mitigated
A tool reads beyond the user’s rights
I
every tool calls the same services with the caller’s actor; the assistant reloads the actor before each tool call; page context ids are hints, filtered by the access guards (src/assistant.rs, src/services/assistant.rs)
Prompt injection through file text or descriptions makes the model act (A4)
T, E
five write tools only, all drafts or comments (no submit, approve, release); the assistant offers them only when the user ticks Allow drafting and may edit; read_file_text marks its output as untrusted; the HTML report reader skips comments, scripts and hidden elements (hidden, aria-hidden, inline display:none/visibility:hidden – not text styled invisible by a style sheet or colour) (src/htmltext.rs); the system prompt says tool results are data; after reading something an outsider could not read, the four text-writing tools refuse (taint) (src/mcp/tools.rs, src/assistant.rs)
partial: the untrusted marker is advice to the model; no taint for external MCP clients (F15)
Exfiltration through the rendered answer
I
markdown-lite escapes everything, has no images and no free links (only /i/<item> links); the CSP blocks foreign images (src/assistant.rsrender_markdown)
src/assistant.rsmarkdown_is_rendered_safely
mitigated
Data sent to the model provider
I
off unless an administrator configures a key; the dialogs name provider and host; only question and answer text are stored, threads expire (30 days) and are owner-only; audit entries carry counts, not content (src/services/assistant_threads.rs, src/services/assistant.rs)
tests/assistant_threads.rs
accepted (documented in docs/src/guide/assistant.md, SECURITY.md)
The LLM client is turned against internal hosts
I
the base URL is administrator configuration; no redirects, key in a sensitive header, errors without URL (src/llm/http.rs)
12 steps, 16 000 output tokens per call, 800 000 tokens per run, 240 s, four runs at a time; no per-user quota or rate limit
–
open (F16; OPEN_WORK per-user daily budget)
teamcraft mcp bridge leaks its token
I
token from a file or TEAMCRAFT_TOKEN, never printed; it follows redirects with the default policy (reqwest drops Authorization when host or port change) and accepts http:// servers (src/main.rs)
browser flow to a loopback callback with PKCE S256 and state; one-time code (hashed, 5 minutes, deleted on any exchange attempt); the result is a PAT (sliding, 30 idle days), audited desktop.authorize (src/services/desktop.rs, desktop/src/signin.rs)
the OS keychain (Windows Credential Manager, macOS Keychain, Secret Service), written and read back before it counts; a token file of older versions moves into it at the next start. Without a keychain: the file token in the app’s config folder (mode 0600 on Unix, the profile folder’s permissions on Windows) and a warning at the start and in Settings (desktop/src/settings.rs)
rustls with certificate validation; API paths are checked so the token goes only to the configured server (desktop/src/api.rscheck_path); an http:// server URL to another computer is accepted but warned about in the connect dialog, on the sign-in step and in Settings (desktop/src/settings.rsinsecure_http)
the daily release check (opt-out) asks only api.github.com for the latest release, without redirects and with a 10 s timeout; a version from the answer is used only if it is a plain x.y.z; the notice opens a fixed release page – the app downloads and installs nothing. GitHub sees the address and app version once a day (desktop/src/update.rs)
every server-supplied name goes through safe_name / normalize_folder (no .., no separators, reserved Windows names) before join_rel (desktop/src/layout.rslocal_rel)
reports (panics, uncaught UI errors, sessions that ended unexpectedly) are written locally and redacted before writing: home directory → ~, URLs → [url], tc_pat_/tc_sess_ tokens → [token]; no server address, user name, token or file contents; at most 20 reports of 64 KiB. Nothing is uploaded: the user sees the whole text and opens a prefilled GitHub issue themselves (the address is built in Rust from the stored reports, never from text of the web view), copies or deletes it (desktop/src/crash.rs, desktop/ui/js/crash.js)
A test installation on one computer: a private PostgreSQL cluster and the
server, started by teamcraft local or by the desktop app’s Try TeamCraft on
this computer (src/localserver.rs, desktop/src/localserver.rs). Not a
deployment: one user’s computer, no TLS, no backups.
Threat
STRIDE
Mitigation
Tests
Status
Another computer reaches the server or the database
S / I
the server binds 127.0.0.1:<port> (overlay wins over TEAMCRAFT_BIND_ADDR); PostgreSQL gets listen_addresses = '127.0.0.1', a free port per start and no Unix socket (local_conf)
Another account on the same computer uses the database
S / I
initdb with scram-sha-256 and a random 32-character password (OS RNG), written 0600 to <data>/db-password and given to initdb through a temporary 0600 file, never on a command line; the data directory is the user’s own
src/localserver.rspasswords_are_random_and_alphanumeric, private_files_are_owner_only; tests/local_server.rs (mode of db-password)
mitigated; on Windows the profile folder’s permissions apply
Another local account opens the web UI first and becomes admin
E
the first-start page is offered only while no account exists and the server listens on loopback (as for every local start); the desktop app opens the browser right after the server is ready
--exit-with-stdin: the app holds a pipe to the server’s stdin; its end (also a crash or kill of the app) shuts the server down and pg_ctl stop runs; a cluster left over by a killed server is stopped at the next start
tests/local_server.rs; release job local-server (smoke test of each installer)
mitigated; a killed server leaves PostgreSQL until the next start
Tampered PostgreSQL programs in the installer
T
the release job takes theseus-rs builds over GitHub and checks the release’s .sha256; the installers are not code-signed
release job local-server
partial: the checksum comes from the same release (integrity, not authenticity); no code signing yet
CAD conversions run in the server process
E
the bundled binary is called teamcraft-server; preview::worker_exe accepts that name, so conversions keep their isolated child process with limits
command line only (teamcraft backup, restore); no web or API route (src/main.rs)
–
mitigated (B6)
A corrupt or incomplete backup
T
every object is re-hashed while copied, every object the database refers to must exist; a failed backup removes what it wrote; the manifest is renamed into place last (src/backup.rs)
tests/backup_proofing.rs, tests/backup_errors.rs
mitigated
Restore of a tampered or broken backup
T
manifest, dump checksum, object count and every object hash are checked before anything is written; a non-empty database needs --force; pg_restore runs in one transaction (src/backup.rsverify_backup, ensure_database_empty)
mitigated; the manifest is not signed (whoever can write the backup can rewrite both)
The backup leaks credentials
I
the dump holds password, session and token hashes and the webhook secrets; files are written with the process umask (no restrictive mode), not encrypted; the database password goes only through PGPASSWORD (src/backup.rs)
An administrator’s SVG logo runs script in the origin of other users
E, I
the type is decided from the content; SVG with scripts, event handlers, animations, foreignObject, entity declarations or href/src/url(…) references other than #id and data:image/… is refused; logos are shown only through <img> (no script runs there) and served with Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; sandbox and nosniff; 512 KiB and 4096 px caps, bitmaps whose size cannot be read (PNG, JPEG, WebP) are refused (src/services/branding.rsvalidate, src/web/branding.rsimage)
src/services/branding.rs unit tests (refuses_active_svg_content, refuses_external_references_in_an_inkscape_svg, huge_webp_and_bitmaps_without_a_readable_size_are_refused, …); tests/branding.rs
mitigated; the SVG check is a deny list, the sandbox and <img> are the real barrier
A non-administrator replaces the logos
E
Actor::require(Admin) in branding::set and reset; every change audited (branding.set, branding.reset)
off unless TEAMCRAFT_METRICS_TOKEN (at least 16 characters) is set; bearer token compared without early exit; no label comes from a request (no paths, no users) (src/metrics.rs, src/lib.rsmetrics_endpoint)
src/metrics.rstoken_comparison; tests/metrics.rs
mitigated; a reverse proxy should not expose /metrics publicly
Each open one has an entry in docs/OPEN_WORK.md (section 2, Security);
fixed ones are struck through and name the change.
Finding
Area
F1
No rate limit or lockout for sign-in – fixed (fix/security-web-auth): per username and client address, 429; the own password change (web form and REST) counts on the same per-username counter; the desktop token exchange is not throttled (see the table above)
authentication
F2
PATs have no scopes – fixed (feat/scoped-tokens); the desktop token keeps full rights (expires after 30 days without use, sliding)
tokens
F3
Sign-in, failed sign-in and sign-out are not in the audit log – fixed (fix/security-web-auth)
audit
F4
CSRF: a request without Origin and Referer passes fixed (#111); X-Forwarded-Host is trusted as sent (documented)
web
F5
No HSTS fixed (#111): sent when the public URL is https
web
F6
ID token signatures are not verified, yet the issuer may be plain http://
OIDC
F7
A user removed at the IdP keeps a running session until it expires – mitigated (#173 step 9): OpenID Connect back-channel logout ends the sessions when the provider sends it; idle lifetime for SSO users’ tokens; report of SSO users without sign-in. Without back-channel logout a session runs until it expires (a need-to-know group with a maximum sign-in age hides itself after that age, #151 step 2)
OIDC
F8
Downloads do not re-check the blob hash (tampering on disk shows only in vault check or a backup)
vault
F9
No disk quota per user or in total fixed: optional quotas per user and for the vault (services::quotas)
uploads
F10
Served Content-Type is the client’s (the download headers are now pinned by a test)
downloads
F11
No memory limit for the preview and text children on Windows; 3MF entries up to 2 GiB
parsers
F12
The parser children inherit the server’s environment fixed: cleared
parsers
F13
XLSX/ODS are unpacked in the server process without a cap on the unpacked size
parsers
F14
The Fuzzing workflow never built a target (musl default; fixed in fix/fuzz-build-target: builds for the GNU host). simmesh, pdftext, resultscan not fuzzed (the no-panic lints and the 3DS fix are in main, #106/#108)
parsers
F15
Prompt injection: the untrusted marker is advisory; no taint for external MCP clients
assistant / MCP
F16
No per-user quota or rate limit for the assistant and MCP
assistant / MCP
F17
The teamcraft mcp bridge follows redirects and accepts http://
MCP
F18
Desktop token in a plain file; http:// server URLs accepted – fixed (ga/desktop-security): OS keychain with a warned file fallback, a warning for http:// servers
desktop
F19
Backups are written with the process umask, unencrypted, with an unsigned manifest
backup
Fixed while writing this model: a password change kept the user’s other
sessions alive (users::change_own_password, test
changing_the_own_password_ends_the_other_sessions).